SpringSecurity6+MyBatisでroleを使った認可(設定編)

技術

SpringSecurity6系は5.3までと比べるとSecurityConfigの書き方が変わってるので勉強を兼ねて独自のログイン画面を用意しroleを使った認可機能をjavaで作成してみました。

またMyBatisとH2 Databaseを使うことでDB構築をせずとも、手軽にDBと連携できるようにしてみました。

なお、記載内容が多くなったため、記事を2つに分けてます。

後編は以下をご参照ください。

開発環境

私が構築した時の開発環境は以下の通りとなります。

環境バージョン
Java17
SpringBoot3.4.3
SpringSecurity6.4.3
thymeleaf3.1.3.RELEASE
MyBatis3.0.4
H2 Database2.3.232
lombok1.18.36

ソース構成

ソース構成は以下の通りです。

login-role-project
├── src
│   ├── main
│   │   ├── java
│   │   │   └── com
│   │   │       └── example
│   │   │           └── loginroleproject
│   │   │               ├── config
│   │   │               │   ├── SecurityConfig.java
│   │   │               │   └── SecurityProperties.java
│   │   │               ├── controller
│   │   │               │   ├── AdminController.java
│   │   │               │   ├── LoginController.java
│   │   │               │   └── MemberController.java
│   │   │               ├── entity
│   │   │               │   ├── LoginUsers.java
│   │   │               │   └── UserRole.java
│   │   │               ├── repository
│   │   │               │   ├── LoginUsersMapper.java
│   │   │               │   └── UserRoleMapper.java
│   │   │               ├── security
│   │   │               │   ├── CustomAuthenticationSuccessHandler.java
│   │   │               │   ├── LoginUsersDataInitializer.java
│   │   │               │   └── LoginUsersDetailsService.java
│   │   │               └── LoginRoleProjectApplication.java
│   │   └── resources
│   │       ├── com
│   │       │   └── example
│   │       │       └── loginroleproject
│   │       │           └── repository
│   │       │               ├── LoginUsersMapper.xml
│   │       │               └── UserRoleMapper.xml
│   │       ├── static
│   │       │   └── css
│   │       │       └── styles.css
│   │       ├── templates
│   │       │   ├── admin
│   │       │   │   └── home.html
│   │       │   ├── member
│   │       │   │   └── home.html
│   │       │   └── login.html
│   │       ├── application.yml
│   │       └── schema.sql
└── pom.xml

pom.xml

必要なライブラリを導入するためにpom.xmlを以下のように記述します。

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.4.3</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>
    <groupId>com.example</groupId>
    <artifactId>login-role-project</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>login-role-project</name>
    <description>login-role-project</description>
    <url/>
    <licenses>
        <license/>
    </licenses>
    <developers>
        <developer/>
    </developers>
    <scm>
        <connection/>
        <developerConnection/>
        <tag/>
        <url/>
    </scm>
    <properties>
        <java.version>17</java.version>
    </properties>
    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-thymeleaf</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-validation</artifactId>
        </dependency>
        <dependency>
            <groupId>org.mybatis.spring.boot</groupId>
            <artifactId>mybatis-spring-boot-starter</artifactId>
            <version>3.0.4</version>
        </dependency>

        <dependency>
            <groupId>com.h2database</groupId>
            <artifactId>h2</artifactId>
            <scope>runtime</scope>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.springframework.security</groupId>
            <artifactId>spring-security-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-compiler-plugin</artifactId>
                <configuration>
                    <annotationProcessorPaths>
                        <path>
                            <groupId>org.projectlombok</groupId>
                            <artifactId>lombok</artifactId>
                        </path>
                    </annotationProcessorPaths>
                </configuration>
            </plugin>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
                <configuration>
                    <excludes>
                        <exclude>
                            <groupId>org.projectlombok</groupId>
                            <artifactId>lombok</artifactId>
                        </exclude>
                    </excludes>
                </configuration>
            </plugin>
        </plugins>
    </build>

</project>

application.ymlの作成

アプリの設定を行うapplication.ymlを以下のように記述します。

spring:
  datasource:
    url: jdbc:h2:mem:testdb
    driver-class-name: org.h2.Driver
    username: sa
    password:
  h2:
    console:
      enabled: true
      path: /h2-console

logging:
  level:
    org.springframework.security: DEBUG

security:
  login-url: /login
  logout-url: /logout
  permit-urls:
    - /login
    - /css/**
    - /js/**
    - /images/**
  users:
    - username: admin
      password: admin123
      role: ADMIN
    - username: user1
      password: user123
      role: MEMBER
    - username: user2
      password: user456
      role: MEMBER
  roles:
    admin: ADMIN
    user: MEMBER
  paths:
    admin: /admin/**
    user: /member/**
  h2-console-url: /h2-console/**

mybatis:
  type-aliases-package: com.example.loginroleproject.entity
  configuration:
    map-underscore-to-camel-case: true

以下を指定することでh2をDBとして使用することしてます。h2のDBにログインする時のpasswordは未入力で大丈夫です。

なお、h2は軽量でローカルでの開発には便利ですが、安定性やスケーラビリティに欠けるので商用利用はしないように留意ください。

spring:
  datasource:
    url: jdbc:h2:mem:testdb
    driver-class-name: org.h2.Driver
    username: sa
    password:

以下はh2のDBの中身を見れるようにh2-consoleを有効化してます。

h2:
  console:
    enabled: true
    path: /h2-console

以下はデバッグ用にSpringSecurityのログレベルをDEBUGに指定してます。

org.springframework.security: DEBUG

securityプリフィックス以下にある値は後述で示すSecurityPropertiesクラスで使用してます。

例えばlogin-urlにはログイン用のURLを記載します。

permit-urlsには未認証でもアクセスできるURLを記載します。

CSSやJSや画像などの静的ファイルは未認証でもアクセスできないと使用できないため許可します。

  login-url: /login
  logout-url: /logout
  permit-urls:
    - /login
    - /css/**
    - /js/**
    - /images/**

usersにはログインで使用するユーザー名とパスワードを記載します。

  users:
    - username: admin
      password: admin123
      role: ADMIN
    - username: user1
      password: user123
      role: MEMBER
    - username: user2
      password: user456
      role: MEMBER

rolesにはどのようなロールがあるかを記載します。

  roles:
    admin: ADMIN
    user: MEMBER

type-aliases-packageで指定したパッケージ配下のクラスはパッケージ名を省略してMyBatisのXMLで使用できます。

map-underscore-to-camel-case: trueを指定することでデータベースのカラム名をキャメルケースに自動変換できるようにします。

mybatis:
type-aliases-package: com.example.loginproject.entity
configuration:
map-underscore-to-camel-case: true

schema.sqlの作成

アプリケーション起動時にDBのテーブルが作成されるようにschema.sqlを作成します。

CREATE TABLE IF NOT EXISTS LOGIN_USERS (
    USERNAME VARCHAR (50) PRIMARY KEY,
    PASSWORD VARCHAR (100) NOT NULL,
    ENABLED BOOLEAN NOT NULL
);

CREATE TABLE IF NOT EXISTS USER_ROLES (
    USERNAME VARCHAR (50) NOT NULL,
    ROLE VARCHAR (20) NOT NULL,
    PRIMARY KEY (USERNAME, ROLE),
    FOREIGN KEY (USERNAME) REFERENCES LOGIN_USERS (USERNAME)
);

application.ymlを扱うクラスの作成

application.ymlにlogin-urlといったログイン用のURLなどを定数として定義しました。

それらの定数を扱うためのクラスであるSecurityPropertiesクラスを作成します。

package com.example.loginroleproject.config;

import java.util.List;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import org.springframework.validation.annotation.Validated;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import lombok.Getter;
import lombok.Setter;

@Getter
@Setter
@Component
@Validated
@ConfigurationProperties(prefix = "security")
public class SecurityProperties {
    @NotBlank(message = "Login URL must be specified")
    private String loginUrl;

    @NotBlank(message = "Logout URL must be specified")
    private String logoutUrl;

    @NotEmpty(message = "Permit URLs must be specified")
    private List<String> permitUrls;

    @NotEmpty(message = "Users must be configured")
    private List<UsersConfig> users;

    private RolesConfig roles;

    private PathsConfig paths;

    @NotBlank(message = "H2 console URL must be specified")
    private String h2ConsoleUrl;

    @Getter
    @Setter
    public static class UsersConfig {
        @NotBlank(message = "Username must be specified")
        private String username;

        @NotBlank(message = "Password must be specified")
        private String password;

        @NotBlank(message = "Role must be specified")
        private String role;
    }

    @Getter
    @Setter
    public static class RolesConfig {
        @NotBlank(message = "Admin role must be specified")
        private String admin;

        @NotBlank(message = "User role must be specified")
        private String user;
    }

    @Getter
    @Setter
    public static class PathsConfig {
        @NotBlank(message = "Admin path must be specified")
        private String admin;

        @NotBlank(message = "User path must be specified")
        private String user;
    }
}

lombokのgetterとsetterアノテーションによってgetterとsetterメソッドを自作しないで自動的に生成されるようにしてます。


Validated、NotBlankとNotEmptyアノテーションによってymlファイルから値を読み込んだ時に値がない場合にエラーとなるようにしてます。

アプリ起動時にバリデーションが行われることでアプリ起動に必要な値がすべて揃ってるかをチェックしてます。

なお、コレクションフレームワーク(List型)はNotBlankが使用できないため、NotEmptyでチェックしてます。


ConfigurationPropertiesアノテーションによってapplication.yml上のsecurityプレフィックス配下にあるものをすべてこのクラスの変数に格納するようにしてます。

permit-urlsプレフィックスが以下のようにハイフンを使って複数値を定義しているため、List<String>でpermitUrlsを宣言してます。

  permit-urls:
    - /login
    - /css/**
    - /js/**
    - /images/**

usersプレフィックスが複数の項目を持つため、UsersConfigというインナークラスを作成しています。

rolesとpathsプレフィックスも複数の項目を持つため、RolesConfigとPathsConfigというインナークラスを作成してます。

  users:
    - username: admin
      password: admin123
      roles:
        - ADMIN
    - username: user1
      password: user123
      roles:
        - MEMBER
    - username: user2
      password: user456
      roles:
        - MEMBER

SpringSecurityの設定

SpringSecurityの設定を行うクラスを作成します。

package com.example.loginroleproject.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
import com.example.loginroleproject.security.CustomAuthenticationSuccessHandler;
import lombok.RequiredArgsConstructor;

/**
 * Spring Securityの設定クラス 認証・認可の設定とセキュリティ関連の設定を行います
 */
@RequiredArgsConstructor
@Configuration
public class SecurityConfig {
    private static final String X_FRAME_OPTIONS_HEADER = "X-Frame-Options";
    private static final String X_FRAME_OPTIONS_SAMEORIGIN = "SAMEORIGIN";
    private static final String X_FRAME_OPTIONS_DENY = "DENY";

    private final SecurityProperties securityProperties;
    private final CustomAuthenticationSuccessHandler authenticationSuccessHandler;

    /**
     * Spring Securityの設定を構成します
     *
     * @param http HttpSecurityオブジェクト
     * @return 設定済みのSecurityFilterChain
     * @throws Exception 設定中にエラーが発生した場合
     */
    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
            .authorizeHttpRequests(auth -> auth
                .requestMatchers(securityProperties.getPermitUrls().toArray(String[]::new)).permitAll()
                .requestMatchers(securityProperties.getH2ConsoleUrl()).permitAll()
                .requestMatchers(securityProperties.getPaths().getAdmin()).hasRole(securityProperties.getRoles().getAdmin())
                .requestMatchers(securityProperties.getPaths().getUser()).hasRole(securityProperties.getRoles().getUser())
                .anyRequest().authenticated()
            )
            .formLogin(login -> login
                .loginPage(securityProperties.getLoginUrl())
                .successHandler(authenticationSuccessHandler)
                .permitAll()
            )
            .logout(logout -> logout
                .logoutRequestMatcher(new AntPathRequestMatcher(securityProperties.getLogoutUrl()))
                .logoutSuccessUrl(securityProperties.getLoginUrl())
                .permitAll()
            )
            .csrf(csrf -> csrf
                .ignoringRequestMatchers(securityProperties.getH2ConsoleUrl())
            )
            .headers(headers -> headers
                .addHeaderWriter((request, response) -> {
                    if (new AntPathRequestMatcher(securityProperties.getH2ConsoleUrl()).matches(request)) {
                        response.setHeader(X_FRAME_OPTIONS_HEADER, X_FRAME_OPTIONS_SAMEORIGIN);
                    } else {
                        response.setHeader(X_FRAME_OPTIONS_HEADER, X_FRAME_OPTIONS_DENY);
                    }
                })
            );
        return http.build();
    }

    /**
     * パスワードエンコーダーを提供します
     *
     * @return BCryptPasswordEncoderのインスタンス
     */
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
}

RequiredArgsConstructorアノテーションはfinal修飾子が付与されたメンバ変数を引数に持つコンストラクタを自動生成します。

これによって以下のようなコンストラクタを作らずともSecurityPropertiesクラスをインジェクションできるようにし、SecurityConfigクラスでSecurityPropertiesとCustomAuthenticationSuccessHandlerクラスを使用可能としてます。

    public SecurityConfig(final SecurityProperties securityProperties, final CustomAuthenticationSuccessHandler authenticationSuccessHandler) {
        this.securityProperties = securityProperties;
        this.authenticationSuccessHandler = authenticationSuccessHandler;
    }

以下によってapplication.ymlのpermit-urlsとh2-console-urlプレフィックスで定義したURLについてはログイン認証をせずにアクセスできるようにしてます。

.requestMatchers(securityProperties.getPermitUrls().toArray(String[]::new)).permitAll()
.requestMatchers(securityProperties.getH2ConsoleUrl()).permitAll()

以下によって「/admin/**」というURLはAdminロールを持つユーザーのみアクセスできるようにしてます。

「/member/**」というURLはMemberロールを持つユーザーのみアクセスできるようにしてます。

この制御によってroleによる認可を実現しています。

.requestMatchers(securityProperties.getPaths().getAdmin()).hasRole(securityProperties.getRoles().getAdmin())
.requestMatchers(securityProperties.getPaths().getUser()).hasRole(securityProperties.getRoles().getUser())

以下によってログイン画面はapplication.ymlのlogin-urlプレフィックスに定義したURLであることを指定してます。

.loginPage(securityProperties.getLoginUrl())

以下によってログイン成功時の処理はCustomAuthenticationSuccessHandlerクラスを実行するように指定してます。

CustomAuthenticationSuccessHandlerは後続で作成するクラスになります。

.successHandler(authenticationSuccessHandler)

logoutRequestMatcherによってログアウトの処理を実行するURLを指定してます。

logoutSuccessUrlによってログアウト処理が成功した後のリダイレクト先のURLを指定してます。

.logoutRequestMatcher(new AntPathRequestMatcher(securityProperties.getLogoutUrl()))
.logoutSuccessUrl(securityProperties.getLoginUrl())

以下によってh2-consoleのみcsrfトークンを無効化してます。

.csrf(csrf -> csrf
    .ignoringRequestMatchers(securityProperties.getH2ConsoleUrl())
)

以下によってh2-consoleのみiframeを使用可能にしてます。

.headers(headers -> headers
    .addHeaderWriter((request, response) -> {
        if (new AntPathRequestMatcher(securityProperties.getH2ConsoleUrl()).matches(request)) {
            response.setHeader(X_FRAME_OPTIONS_HEADER, X_FRAME_OPTIONS_SAMEORIGIN);
        } else {
            response.setHeader(X_FRAME_OPTIONS_HEADER, X_FRAME_OPTIONS_DENY);
        }
    })
);

以下によってパスワードの暗号化にBCryptPasswordEncoderを使用するように指定してます。

@Bean
public PasswordEncoder passwordEncoder() {
    return new BCryptPasswordEncoder();
}

ログイン成功時のHandlerクラス作成

ログイン成功時のHandlerクラスを作成します。

package com.example.loginroleproject.security;

import java.io.IOException;
import java.util.Collection;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
import org.springframework.stereotype.Component;
import com.example.loginroleproject.config.SecurityProperties;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;

/**
 * ユーザーのロールに基づいて適切なURLにリダイレクトするカスタム認証成功ハンドラー
 */
@Component
@RequiredArgsConstructor
public class CustomAuthenticationSuccessHandler implements AuthenticationSuccessHandler {

    private static final String ROLE_PREFIX = "ROLE_";
    private final SecurityProperties securityProperties;

    /**
     * 認証成功時に呼び出され、ユーザーのロールに応じた適切なURLにリダイレクトします
     * 
     * @param request リクエスト
     * @param response レスポンス
     * @param authentication 認証情報
     * @throws IOException I/O例外が発生した場合
     * @throws ServletException サーブレット例外が発生した場合
     */
    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
            Authentication authentication) throws IOException, ServletException {

        Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();

        // 管理者ロールを持つ場合は管理者ホーム画面へリダイレクト
        if (authorities.contains(new SimpleGrantedAuthority(
                ROLE_PREFIX + securityProperties.getRoles().getAdmin()))) {
            response.sendRedirect("/admin/home");
        } else {
            // それ以外の場合は会員ホーム画面へリダイレクト
            response.sendRedirect("/member/home");
        }
    }
}

AuthenticationSuccessHandlerインターフェースを実装することでログイン成功時に実行される処理をカスタマイズしてます。

SpringSecurityが管理しているセッション情報からauthenticationを取得し、ログイン後に管理者ロールを持つユーザーとそうでないユーザーによって呼び出すコントローラーを分けるようにしてます。

セッション情報の登録は後編に記すLoginUsersDetailsServiceで行っています。


なお、記載内容が多くなったため、記事を2つに分けてます。

後編は以下をご参照ください。

コメント

タイトルとURLをコピーしました