15
8

この記事に書いてあること

SpringSecurityとは

SpringFrameworkの認証認可を実現するための仕組み。

認証とは

アプリケーションを使用するユーザーを特定すること。

認可とは

認証したユーザーが行った操作の可否を制御すること。

認証認可はなぜ必要か

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

SpringSecurityを使った実装

認証とリクエスト単位での認可

概念

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

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

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

SecurityFilterChain.drawio (2).png

実装

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

以前のコード

DemoController.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

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

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

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

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

build.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

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);
	}

}

build.gradledependenciesimplementation 'org.springframework.boot:spring-boot-starter-security' を追加する。

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 を追加する。
とりあえずレスポンスとして返す内容は全て同じにしておく。

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に SecurityFilterChainUserDetailsService のConfigurationを記述する。それぞれ以下のように実装していく。

  • test ユーザー権限もしくは管理者権限をもったユーザーがアクセスできる
  • control 管理者権限をもったユーザーのみがアクセスできる
  • noLogin ログインしていなくてもアクセスできる
  • nobodyAccess 誰もアクセスできない(アクセス許可を記述しない)
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という意味で暗号化されていない平文という意味です。1

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

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

noLogin

image.png

それ以外

image.png

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

test noLogin

image.png

control nobodyAccess hogehogehogehogehoge

image.png

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

メソッド単位での認可

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

@SpringBootApplication
@EnableMethodSecurity
public class DemoApplication {

以下略

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

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' を追記。

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を以下のように修正します。

<!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

認証されたユーザー情報を利用する

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

test.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に詰めて表示するように実装。

    @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

おわり。

  1. 実際にアプリケーションを作る際には、DBに保存するパスワードは必ず暗号化(もしくはハッシュ化)しましょう。

15
8
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
15
8