【Spring Boot】Spring Security 認可制御（メソッド指定・パス変数指定など）

# はじめに

Spring Security の認可のうち、**メソッドへの認可**及び**パス変数に基づく認可**について、備忘としてまとめておきます。
HTTP リクエスト（Web リソース）からの認可制御や、認可エラー画面（403エラー画面）のカスタマイズについても、併せて残しておきます。

＜参考サイト・書籍＞
・[Spring Security リファレンス](https://spring.pleiades.io/spring-security/site/docs/current/reference/html5/#servlet-authorization)
・[『Spring 徹底入門』株式会社NTTデータ著（翔泳社・2016年7月刊）](https://www.amazon.co.jp/Spring%E5%BE%B9%E5%BA%95%E5%85%A5%E9%96%80-Spring-Framework%E3%81%AB%E3%82%88%E3%82%8BJava%E3%82%A2%E3%83%97%E3%83%AA%E3%82%B1%E3%83%BC%E3%82%B7%E3%83%A7%E3%83%B3%E9%96%8B%E7%99%BA-%E6%A0%AA%E5%BC%8F%E4%BC%9A%E7%A4%BENTT%E3%83%87%E3%83%BC%E3%82%BF/dp/4798142476)

# 1. メソッド実行前の認可制御の設定

メソッド実行前の認可制御から確認していきます。

まず、次のように `@EnableGlobalMethodSecurity` アノテーションを使用して、メソッドに対する認可処理を有効にする必要があります。
これは、コンフィグレーションクラス（`WebSecurityConfigurerAdapter` を拡張したクラス）に設定します。

```SecurityConfig.java
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    // 略
}
```
定義式（Expression）部分に、`prePostEnabled = true` とすることにより、以下で使用する `@PreAuthorize` などのアクセス権設定のためのアノテーションが使用できるようになります。

## 1-1. メソッドの引数（パス変数）を使用して指定

### 1-1-1. 基本的な指定

次のように、コントローラーのメソッドに、`@PreAuthorize` アノテーションを使用してアクセス権を定義します。

```UserController.java
// 略
@Controller
@RequestMapping
public class UserController {
    // 略
    @PreAuthorize("#username == authentication.principal.username")
    @GetMapping("/users/{username}")
    public String userInfo(@PathVariable String username, Model model) {
        // 略
    }
}
```
定義式 `("#username == authentication.principal.username")` のうち、`#username` は、userInfo メソッドの引数 `String username` に対応しています。
`authentication.principal.username` は、現在ログイン中のユーザー名を表しています。これは、`authentication.name` 又は `principal.username` と書いても構いません。

これは、自身のユーザー名がパス変数に指定されている場合のみアクセスを認可する例です。

### 1-1-2. 一対多の関係にある場合の指定

次に、User エンティティーと Message エンティティーが、一対多の関係にある場合の例を書くと次のようになります。
これは、User は自身に紐付いている Message に対してのみ、データの更新が許可される例です。

```MessageController.java
// 略
@Controller
@RequestMapping("/message")
public class MessageController {
    // 略
    @PreAuthorize("#message.user.username == authentication.name")
    @PostMapping("/edit/{id}")
    public String updateMessage(@Valid Message message, BindingResult result, @PathVariable Integer id) {
        // 略
    }
}
```

## 1-2. ユーザーのROLE（権限情報）を使用して指定

以下は、ユーザーの有する ROLE（権限情報）に基づきアクセス権を設定する例です。

```UserController.java
// 略
@Controller
@RequestMapping
public class UserController {
    // 略
    @PreAuthorize("hasRole('USER')")
    @GetMapping("/users/{username}")
    public String userInfo(@PathVariable String username, Model model) {
        // 略
    }
}
```
定義式（Expression）の `("hasRole('USER')")` で、ROLE（権限情報） に `USER` を有しているユーザーのみにアクセス権を認可するようにしています。
この設定の場合は、USER 権限を有しているユーザーは全ての username についての URL が参照できることになります。

## 1-3. 定義用のメソッド（Been）を作成して指定

メソッドの引数だけでは設定が難しい内容は、次のように専用のメソッド（Been）を作成して定義を行います。

**①コントローラー**
ここでも、`@PreAuthorize` アノテーションを使用して定義します。

```UserController.java
// 略
@Controller
@RequestMapping
public class UserController {
    // 略
    @PreAuthorize("@webSecurity.checkUserId(authentication, #id)")
    @GetMapping("/user/{id}")
    public String userPage(@PathVariable Integer id, Model model) {
        // 略
    }
}
```

定義内容は、`@webSecurity.checkUserId(authentication, #id)`の部分です。
構文としては、次のようになっています。

```
@クラス名.メソッド名(引数...)
```

**②定義用メソッド**
次のような専用のクラス（`WebSecurity.java`）を作成して、認可を行うメソッドを定義します。
メソッドの内容まで書いておきましたが、これはあくまで一例です。必要に応じて、適宜作成することになります。

```WebSecurity.java
package com.example.demo;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Component;

@Component
public class WebSecurity {
    
    @Autowired
    private UserDetailsServiceImpl userDetailsServiseImpl;
    
    public boolean checkUserId(Authentication authentication, int id) {
        UserDetails userDetails = (UserDetails) authentication.getPrincipal();
        User user = userDetailsServiseImpl.getUserByUsername(userDetails.getUsername());
        return id == user.getId();
    }
}
```

＜参考サイト＞
・[Web セキュリティ式で Bean を参照する](https://spring.pleiades.io/spring-security/site/docs/current/reference/html5/#el-access-web)
・[Spring SecurityのPreAuthorizeで独自メソッドを呼び出す](https://qiita.com/kazuhiro1982/items/eca8d7e5204fd70a865d)

## 1-4. 条件を組み合わせて指定

以上で見てきた内容は、組み合わせて定義することもできます。
例えば、次のような感じです。

```Sample.java
    @PreAuthorize("hasRole('ADMIN') or #username == principal.username")
```

```Sample.java
    @PreAuthorize("hasRole('ADMIN') or @webSecurity.checkUserId(authentication, #id)")
```

# 2. メソッド実行後の認可制御の設定

次に、メソッド実行後の認可制御です。
この場合も `@EnableGlobalMethodSecurity` アノテーションを使用して、メソッドに対する認可処理を有効にしておきます。
これにより、`@PostAuthorize` アノテーションが使用できるようになります。

```SecurityConfig.java
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    // 略
}
```

## 2-1. メソッドの戻り値を使用して指定

メソッド実行後の認可制御には、`@PostAuthorize` アノテーションを使用します。

```UserDetailsServiceImpl.java
package com.example.demo;

import java.util.List;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.access.prepost.PostAuthorize;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class UserDetailsServiceImpl implements UserDetailsService {
    @Autowired
    private UserDao userDao;
    // 略
    @PostAuthorize("returnObject.username == principal.username")
    @Transactional(readOnly = true)
    public User findUser(Integer userId) {
        User user = userDao.findById(userId).orElseThrow(RuntimeException::new);
        return user;
    }
    // 略
}
```

定義式（Expression）は、`"returnObject.username == principal.username"`の部分です。
メソッドの戻り値は、`returnObject` で取得することができます。

# 3. HTTP リクエストに対する認可制御

次に、HTTP リクエスト（Web リソース）から認可制御を行う場合です。
定義する内容は、「[1. メソッド実行前の認可制御の設定](#1-メソッド実行前の認可制御の設定)」で記載した内容と基本的に同じです。

## 3-1. メソッドの引数を使用して指定

Web リソースに対する認可は、次のように、コンフィギュレーションクラスの `HttpSecurity クラス`の設定のところに定義します。

**①コンフィギュレーションクラス**

```SecurityConfig.java
// 略
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    // 略
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
            .antMatchers("/signup").permitAll()
            .antMatchers("/users/{username}/**").access("#username == principal.username")
            .anyRequest().hasAnyRole("ADMIN", "USER").and()
            .formLogin().loginPage("/login").defaultSuccessUrl("/").permitAll().and()
            .logout().permitAll().and()
            .exceptionHandling().accessDeniedPage("/accessDenied");
    }
}
```

定義している部分は、次の１行です。

```Sample.java
    .antMatchers("/users/{username}/**").access("#username == principal.username")
```

`principal.username` で現在ログイン中のユーザー名を取得しています。
`#username` には変数が入ります。これは、次の「②コントローラー」のメソッドの引数が充てられることになります。

**②コントローラー**
該当するコントローラーは次のとおりです。
ここのメソッドの引数 `String username` が、上記のコンフィギュレーションクラスの `#username` に充てられることになります。

```UserController.java
// 略
@Controller
@RequestMapping
public class UserController {
    // 略
    @GetMapping("/users/{username}")
    public String userInfo(@PathVariable String username, Model model) {
        // 略
    }
}
```

## 3-2. 定義用のメソッド（Been）を作成して指定

Web リソースに対する認可でも、専用のメソッドを作成して定義することができます。

**①コンフィギュレーションクラス**

```SecurityConfig.java
// 略
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    // 略
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
            .antMatchers("/signup").permitAll()
            .antMatchers("/message/edit/{messageId}/**").access("@webSecurity.checkMessageId(authentication, #messageId)")
            .anyRequest().hasAnyRole("ADMIN", "USER").and()
            .formLogin().loginPage("/login").defaultSuccessUrl("/").permitAll().and()
            .logout().permitAll().and()
            .exceptionHandling().accessDeniedPage("/accessDenied");
    }
}
```

定義している部分は、次の１行です。

```Sample.java
    .antMatchers("/message/edit/{messageId}/**").access("@webSecurity.checkMessageId(authentication, #messageId)")
```

`@webSecurity` クラスの `checkMessageId` メソッドで、２つの引数（`authentication, #messageId`）を使用して定義しています。


**②定義用メソッド**

定義用のメソッド（Been）は次のとおりです。クラスファイル（`WebSecurity.java`）を一つ作成しています。
メソッドの内容はあくまで一例です。必要に応じて、適宜作成してください。

```WebSecurity.java
// 略
@Component
public class WebSecurity {
    
    @Autowired
    private MessageService messageService;
    
    public boolean checkMessageId(Authentication authentication, Integer messageId) {
        Message message = messageService.getMessage(messageId);
        UserDetails userDetails = (UserDetails) authentication.getPrincipal();
        return userDetails.getUsername().equals(message.getUser().getUsername());
    }
}
```

＜参考サイト＞
・[SpringSecurityでEL式を拡張してカスタムルールを作るときのメモ](https://www.kimullaa.com/entry/2016/04/19/SpringSecurity%E3%81%A7EL%E5%BC%8F%E3%82%92%E6%8B%A1%E5%BC%B5%E3%81%97%E3%81%A6%E3%82%AB%E3%82%B9%E3%82%BF%E3%83%A0%E3%83%AB%E3%83%BC%E3%83%AB%E3%82%92%E4%BD%9C%E3%82%8B%E3%81%A8%E3%81%8D%E3%81%AE)

## 3-3. 条件を組み合わせて指定

Web リソースに対する認可でも、次のように幾つかの条件を組み合わせて認可の定義をすることができます。

```Sample.java
    .antMatchers("/message/edit/{messageId}/**").access("hasRole('ADMIN') or @webSecurity.checkMessageId(authentication, #messageId)")
```

# 4. 認可エラー時のレスポンス画面のカスタマイズ

アクセスが拒否された場合は、HTTP403 エラーが生じます。
デフォルトでは、次のような画面が表示されます。
![2021-07-27 222538.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/551412/43396e7d-9b82-8253-ec96-d6a92291db4c.png)
ここで独自のエラー画面を表示するには、以下のように設定を行います。

**①コンフィギュレーションクラス**
コンフィグレーションクラスでは、次のように定義します。

```SecurityConfig.java
// 略
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    // 略
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.// 略
            .exceptionHandling().accessDeniedPage("/accessDenied");
    }
}
```

関係部分は、次の１行です。
ここで、カスタム画面のURL `"/accessDenied"` を指定しています。

```Sample.java
    http.// 略
        .exceptionHandling().accessDeniedPage("/accessDenied");
```

**②HTMLファイル**
適当ですが、次のように作成しておきました。

```src/main/resources/templates/accessDenied.html
<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <title>アクセスエラー</title>
</head>
<body>
    <h2>アクセスエラー</h2>
    <a href="/">トップページ</a></body>
</body>
</html>
```

**③コントローラー**
コントローラーでも、リクエストURL `/accessDenied` に対応する HTML ファイルを指定しておきます。

```Sample.java
    // 略
    @GetMapping("/accessDenied")
    private String accessDenied() {
        return "accessDenied";
    }
    // 略
```

設定は以上です。

アクセス拒否がされた場合に、次のように表示されれば成功です。
![2021-07-27 222712.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/551412/0b24c62e-b8d5-a66d-5b3d-0076062aa438.png)

# さいごに

書籍を読んだだけでは、なかなか理解できなかったため、実際に確認した内容を備忘として残しておきました。
何らかのご参考となれば幸いです。