SpringSecurityの仕組みの初歩と実装例

# この記事に書いてあること
- [プロになるためのSpring入門](https://gihyo.jp/book/2023/978-4-297-13613-0)のSpringSecurityに関する内容を読んだまとめ
- SpringSecurityを使った実装例

# SpringSecurityとは
SpringFrameworkの認証認可を実現するための仕組み。
## 認証とは
アプリケーションを使用するユーザーを特定すること。
## 認可とは
認証したユーザーが行った操作の可否を制御すること。

## 認証認可はなぜ必要か
認証認可の実装がされていないと多くのアプリケーションの機能は成り立たない。
例えばSNSであれば投稿された内容は全てのユーザーが見えるようにしつつ、投稿の編集・削除は投稿したユーザーにしかできないようにする、というような制御が必要になる。

# SpringSecurityを使った実装
## 認証とリクエスト単位での認可
### 概念
認証処理はあらゆるアプリケーションの処理を実行する前に行う必要がある。
SpringSecurityではServletFilterの仕組みを使って、アプリケーションの処理の実行前に以下のような処理を簡単に実装できる。

- リクエスト単位での認可
- 認証済みかどうかの確認
- 認証が必要であればログイン画面を表示する
- セッションにログイン情報を保持する
- 権限がない場合にエラー画面を表示する

ServletFilterで挟み込むSpringSecurityの処理をSecurityFilterChainという。
SecurityFilterChainでどのような処理を行うかはConfigurationで定義する。

![SecurityFilterChain.drawio (2).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1126770/7971b027-db36-0f1c-20db-b7fc0ad264e6.png)

### 実装

https://qiita.com/gengen0719/items/65cab20943bf4cce61ca

以前の記事で用意したデータベースからメッセージを取得して表示する実装に認証の仕組みを挟み込んでみる。

<details><summary>以前のコード</summary>

DemoController.java
```Java
package com.example.demo;

import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;

@Controller
public class DemoController {

    private final MessageMakeService messageMakeService;

    public DemoController(MessageMakeService messageMakeService){
        this.messageMakeService = messageMakeService;
    }

    @GetMapping("test")
    public String showMessage(Model model) {
        model.addAttribute("message", messageMakeService.make());
        return "test";
    }
    
}
```

MessageMakeService.java
```Java
package com.example.demo;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

@Service
public class MessageMakeService {

    private final MessageRepository messageRepository;

    @Autowired
    public MessageMakeService (MessageRepository messageRepository){
        this.messageRepository = messageRepository;
    }

    public String make(){
        return "メッセージは : " + messageRepository.loadMessage();
    }
}

```

MessageRepository.java
```Java
package com.example.demo;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Repository;

@Repository
public class MessageRepository {

    @Autowired
    private JdbcTemplate jdbcTemplate;

    public String loadMessage(){
        String sql = "SELECT MESSAGE FROM TEST";
        return jdbcTemplate.queryForObject(sql, String.class);
    }
}

```

test.html
```html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">

<p style="color:red;" th:text="${message}"></p>

```

build.gradle
```gradle
plugins {
	id 'java'
	id 'org.springframework.boot' version '3.2.5'
	id 'io.spring.dependency-management' version '1.1.4'
}

group = 'com.example'
version = '0.0.1-SNAPSHOT'

java {
	sourceCompatibility = '17'
}

configurations {
	compileOnly {
		extendsFrom annotationProcessor
	}
}

repositories {
	mavenCentral()
}

dependencies {
	implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
	implementation 'org.springframework.boot:spring-boot-starter-web'
	implementation 'org.springframework.boot:spring-boot-starter-data-jdbc'
	compileOnly 'org.projectlombok:lombok'
	annotationProcessor 'org.projectlombok:lombok'
	testImplementation 'org.springframework.boot:spring-boot-starter-test'
	runtimeOnly 'com.mysql:mysql-connector-j'
	testImplementation 'org.junit.jupiter:junit-jupiter:5.7.2'
    developmentOnly("org.springframework.boot:spring-boot-devtools")
}

tasks.named('test') {
	useJUnitPlatform()
}

```

DemoApplication.java
```Java
package com.example.demo;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;

@SpringBootApplication
public class DemoApplication {

	public static void main(String[] args) {
		SpringApplication.run(DemoApplication.class, args);
	}

}

```

</details>

`build.gradle` の `dependencies` に `implementation 'org.springframework.boot:spring-boot-starter-security'` を追加する。

```gradle
plugins {
	id 'java'
	id 'org.springframework.boot' version '3.2.5'
	id 'io.spring.dependency-management' version '1.1.4'
}

group = 'com.example'
version = '0.0.1-SNAPSHOT'

java {
	sourceCompatibility = '17'
}

configurations {
	compileOnly {
		extendsFrom annotationProcessor
	}
}

repositories {
	mavenCentral()
}

dependencies {
	implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
	implementation 'org.springframework.boot:spring-boot-starter-web'
	implementation 'org.springframework.boot:spring-boot-starter-data-jdbc'
	implementation 'org.springframework.boot:spring-boot-starter-security'
	compileOnly 'org.projectlombok:lombok'
	annotationProcessor 'org.projectlombok:lombok'
	testImplementation 'org.springframework.boot:spring-boot-starter-test'
	runtimeOnly 'com.mysql:mysql-connector-j'
	testImplementation 'org.junit.jupiter:junit-jupiter:5.7.2'
    developmentOnly("org.springframework.boot:spring-boot-devtools")
}

tasks.named('test') {
	useJUnitPlatform()
}
```

DemoController.java に新しいエンドポイント `control` `noLogin` `nobodyAccess` を追加する。
とりあえずレスポンスとして返す内容は全て同じにしておく。

```Java
package com.example.demo;

import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;

@Controller
public class DemoController {

    private final MessageMakeService messageMakeService;

    public DemoController(MessageMakeService messageMakeService){
        this.messageMakeService = messageMakeService;
    }

    @GetMapping("test")
    public String showMessage(Model model) {
        model.addAttribute("message", messageMakeService.make());
        return "test";
    }

    @GetMapping("control")
    public String showControl(Model model) {
        model.addAttribute("message", messageMakeService.make());
        return "test";
    }

    @GetMapping("noLogin")
    public String showNoLogin(Model model) {
        model.addAttribute("message", messageMakeService.make());
        return "test";
    } 
    
    @GetMapping("nobodyAccess")
    public String nobodyAccess(Model model) {
        model.addAttribute("message", messageMakeService.make());
        return "test";
    }
}

```

DemoApplication.javaに `SecurityFilterChain` と `UserDetailsService` のConfigurationを記述する。それぞれ以下のように実装していく。
- test　ユーザー権限もしくは管理者権限をもったユーザーがアクセスできる
- control　管理者権限をもったユーザーのみがアクセスできる
- noLogin ログインしていなくてもアクセスできる
- nobodyAccess 誰もアクセスできない（アクセス許可を記述しない）

```Java
package com.example.demo;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.web.SecurityFilterChain;

@SpringBootApplication
public class DemoApplication {

	public static void main(String[] args) {
		SpringApplication.run(DemoApplication.class, args);
	}

	@Bean
	public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http.authorizeHttpRequests(requests -> requests
                .requestMatchers("GET", "/test").hasAnyRole("ADMIN","USER")
				.requestMatchers("GET", "/control").hasAnyRole("ADMIN")
				.requestMatchers("GET", "/noLogin").permitAll())
                .formLogin(login -> login.defaultSuccessUrl("/test"));
		return http.build();
	}

	@Bean
	public UserDetailsService userDetailsService() {
		UserDetails user = User.builder().username("user")
		.password("{noop}user").roles("USER").build();
		UserDetails admin = User.builder().username("admin")
		.password("{noop}admin").roles("ADMIN").build();
		return new InMemoryUserDetailsManager(user,admin);
	}

}
```
`UserDetailsService` には本来データベース等からユーザー名、パスワード、権限を取得する処理を記述しますが、今回は省略のためプログラムにハードコードする方法で記載しています。
`{noop}` と記載している箇所にはパスワードの暗号化（もしくはハッシュ化）のアルゴリズムを記載します。noopはno operationという意味で暗号化されていない平文という意味です。[^2]
[^2]:実際にアプリケーションを作る際には、DBに保存するパスワードは必ず暗号化（もしくはハッシュ化）しましょう。

まずログインしていない状態でそれぞれのエンドポイントにアクセスしてみる。
Controllerに定義していない適当なURLも含める。

- http://localhost:8080/test
- http://localhost:8080/control
- http://localhost:8080/noLogin
- http://localhost:8080/nobodyAccess
- http://localhost:8080/hogehogehogehogehoge

`noLogin` はControllerの処理が実行され、それ以外は http://localhost:8080/login にリダイレクトされて、Springのデフォルトのログイン画面が表示される。

`noLogin`

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1126770/d306e4fe-4a71-5698-332f-8bd5dafd181a.png)

それ以外

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1126770/415eddc2-24dd-c409-5da8-f3824c7bd4c3.png)

ログイン画面で `user` でログインした後にそれぞれのエンドポイントにアクセスする。

`test` `noLogin`

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1126770/0caab57b-9ee8-0fb4-e1cd-dac5d0938b7b.png)

`control` `nobodyAccess` `hogehogehogehogehoge`

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1126770/8a4c4ce1-d538-811b-d01d-4da12e58ebc3.png)

権限に応じてリクエストの実行が制御されています。
`admin` でログインした場合は `test` `control` `noLogin` が表示できるようになります。
ログイン画面やアクセス拒否をされた際の画面はカスタマイズすることもできます。

## メソッド単位での認可
リクエスト単位の制御のほかにメソッド単位での実行制御も行うことができます。
まずConfigurationクラスに `@EnableMethodSecurity` を付与します。
SpringBootの場合はApplicationクラスがConfigurationクラスとして動作するため、DemoApplicationに以下のように付与します。

```Java
@SpringBootApplication
@EnableMethodSecurity
public class DemoApplication {

以下略
```

例えばMessageRepositoryのloadMessageメソッドを管理者権限で制御する場合は `@PreAuthorize` アノテーションを使って以下のように書きます。

```Java
package com.example.demo;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Repository;

@Repository
public class MessageRepository {

    @Autowired
    private JdbcTemplate jdbcTemplate;

    @PreAuthorize("hasAnyRole('ADMIN')")
    public String loadMessage(){
        String sql = "SELECT MESSAGE FROM TEST";
        return jdbcTemplate.queryForObject(sql, String.class);
    }
}
```

エラーになった場合はリクエストで制御した時と同じエラー画面に遷移します。
今の実装ではすべてのURLからloadMessageが呼ばれるため、この実装を行うと管理者以外はエラー画面しか表示されなくなってしまいます。
そのため動作確認後は `@PreAuthorize` を消して元の動作に戻しておきます。

## 画面上の項目の表示を制御する
認証によって作成されたUserDetailsはセッションスコープで保持されています。
そのためthymeleafの記法で利用して、権限の有無で画面の一部の表示非表示を切り替えることもできます。
build.gradleのimplementationに `implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity6'` を追記。
```gradle
dependencies {
	implementation 'org.springframework.boot:spring-boot-starter-web'
	implementation 'org.springframework.boot:spring-boot-starter-data-jdbc'
	implementation 'org.springframework.boot:spring-boot-starter-security'
	implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
	implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity6'
以下省略
```
test.htmlを以下のように修正します。
```html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org" xmlns:sec="http://www.thymeleaf.org/extras/spring-security">

<p style="color:red;" th:text="${message}"></p>
<div sec:authorize="hasRole('ADMIN')">
    <p>管理者のあなただけに表示されていますよ！</p>
</div>
```
管理者でログインした場合だけ指定のメッセージを表示することができます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1126770/537fb94a-b8a9-0c83-962f-38520dce6a0e.png)

# 認証されたユーザー情報を利用する
前述の通り、認証によって作成されたUserDetailsはセッションスコープで保持されています。
そのためnameやroleはJavaロジックで取得することができます。
これを利用すると、例えば画面上にユーザーの名前を表示したり、データベースにレコードを保存する際にそのレコードを作ったユーザーのnameを合わせて保存する、といったことができるようになります。

test.htmlでユーザー名を表示するように修正
```html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org" xmlns:sec="http://www.thymeleaf.org/extras/spring-security">

<div>こんにちは<span th:text="${username}"></span>さん</div>

<p style="color:red;" th:text="${message}"></p>

<div sec:authorize="hasAuthority('ADMIN')">
    <p>管理者のあなただけに表示されていますよ！</p>
</div>
```

ControllerでUserDetailsオブジェクトを受け取り、その値をModelに詰めて表示するように実装。

```Java
    @GetMapping("test")
    public String showMessage(Model model,@AuthenticationPrincipal UserDetails userDetails) {
        model.addAttribute("username", userDetails.getUsername());
        model.addAttribute("message", messageMakeService.make());        
        return "test";
    }
```

userでログインして http://localhost:8080/test にアクセス

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1126770/e71bb132-a75a-bd03-3238-4c17b5cfeaed.png)

おわり。