長い話を抜きにして、早くコードを書きたいという人は1. はじめにを飛ばしてください。
Spring初学者は2. プロジェクトの作成から、
簡易な認証・認可機能を実装したい人は3. 簡易な認証・認可機能の実装から、
実業務向けの認証・認可機能を実装したい人は4. 実業務向け認証・認可機能の実装から読み始めると良いと思います。
1. はじめに
1-1. 背景
まず最初に、認証・認可について説明します。簡単に説明すると、以下のようになります。(詳しく知りたい人への参考ページ:よくわかる認証と認可)
-
認証 (Authentication)
通信相手が誰であるかを確認すること。 -
認可 (Authorization)
画面にアクセスする権限や処理を行う権限など特定の条件に対して、リソースアクセスの権限を与えること。
この認証・認可の機能をSpring Frameworkで作成されたWebアプリケーションに実装する際、Spring Securityを用いることで楽に実装できます。
Spring Securityを用いて、認証・認可機能を実装する記事は多々ありますが、下記のような記事が多いように感じます。
- データベースが使われていない。
- ユーザ名やパスワードがコード上に直書きされている。
- パスワードがハッシュ化されていない。
- 認証・認可に関する重要部分のソースコードしか載っていない(つまり、読みづらい)。
しかし、データベースの使用やパスワードのハッシュ化は当たり前のように行われています。筆者自身が認証・認可機能を持ったWebシステムを作成する際、多くの記事を読み漁ったり、GitHub上のソースコードを解読したりして、大変苦労した経験があります。その経験をもとに、Spring Securityや認証・認可について初学者でも、データベースやハッシュ化が使える & これさえ読めば機能が実装できる記事が必要だと思い、この記事を執筆しました。
また、現場で使われているシステムでは、認証・認可に関するソースコードやデータベースのテーブルなどがどのように実装されているか気になりますよね。そこで、弊社でのシステム構築事例を参考にどのような認証・認可機能が実装されているのか調べてみようと思い、この記事を執筆しました。
1-2. 目的
- GitHubのソースコードを読む必要なく、この記事さえ読めば、初学者でもSpring Securityを用いて認証・認可機能を実装できる。
- 弊社でのシステム構築事例を参考に認証・認可について調査・ヒアリングした内容を実業務向け認証・認可機能の実装方法としてまとめる。
※ 記事の作成過程において、読みやすさを意識した情報の取捨選択、事例の抽象化をしている背景で、実事例そのままではない点はご容赦ください。
1-3. ターゲット
- Spring Securityを用いて、認証・認可機能を実装してみたい人
- 実際の業務で認証・認可機能を開発する人
- GitHub上のコードを読みたくない、記事さえ読めば実装できることを求めている人
1-4. 前提条件
- MVCモデルを理解している。(知らない人向け参考ページ:MVCモデルについてわかりやすく解説します!【初心者向け】)
- Springを用いて、MVCモデルのWebアプリケーションを開発したことがある。
- Gradleの基本的な利用方法を理解している。
1-5. この記事の目標
-
簡易実装と実業務向け実装の2つの実装を行う。
簡易実装とは、 初学者向けの認証・認可処理の実装です。 Spring Security参考資料の実装部分だけを作者がまとめたものになります。Spring Securityの初学者が、自分の端末で動かしてみて、理解を深めるのに最適です。
実業務向け実装とは、 認証・認可処理が実際のシステムでどのように実現されているかを社内ヒアリングして、その内容をベースに実際のシステムに近い認証・認可処理の実装を行います。業務で認証・認可処理を開発する人のための実装です。 -
この記事で紹介する設定・機能が、実案件適用時に検討すべき設定・機能の一つとして理解してもらう。
以下3つの目標は簡易実装・実業務向け実装どちらにも当てはまります。
- この記事のソースコードをコピペすれば、実装ができる。
- H2データベースを使用し、パスワードをハッシュ化する。
- 以下の画像のような、ページ遷移をする認証・認可機能の実現する。(簡易実装・実業務向け実装ともに、ページ遷移は同じ。)
1-6. 開発環境・実行環境
- Eclipse Version: 2020-06 (4.16.0)
- Spring Tools 4 (aka Spring Tool Suite 4) 4.7.1.RELEASE
- Spring boot 2.3.3
- Spring Security 5.3.4
- Java11
- Gradle 6.3
1-7. GitHub
GitHub上のソースコードを読まなくても、この記事さえ読めば機能を実装することができますが、「GitHubを読んだほうが早いや」という人向けにGitHub上にソースコードを載せておきます。
- BasicAuthが、簡易実装の認証・認可機能のソースコード
- AdvancedAuthorizationが実業務向け認可機能のソースコード
となっています。
2. プロジェクトの作成
2-1. パースペクティブをJavaEEに変更
「ウィンドウ」→「パースペクティブ」→「パースペクティブを開く」→「その他」
「JavaEE」を選択して、開く
2-2. 新規プロジェクトの作成
「ファイル」→「新規」→「Spring スターター・プロジェクト」
下記のように、各項目の値を変更して、「次へ」
項目 | 値 |
---|---|
名前 | 任意の値 |
型 | Gradle (Buildship 3.x) |
Javaバージョン | 11 |
成果物 | 任意の値 |
「完了」を押す
2-3. application.propertiesをapplication.ymlに変更(任意)
propertiesとymlは記述法が少し違うだけなので、「application.properties」のままでも問題ありません。変更するかどうかは好みです。
しかし、この記事では、application.ymlを前提にソースコードを載せています。
3. 簡易な認証・認可機能の実装
3-1. 作成するシステムイメージ & プロジェクト構成
下図が作成するWebシステムのイメージ図です。認証処理や認可処理の詳しい処理フローは3-5-1. Spring Security 認証機能の概略と3-6-1. Spring Security 認可機能の概略にて説明します。
下図は、システムのプロジェクト構成となっています。
3-2. 依存関係の設定
build.gradleを記述して、依存関係を設定します。
**"build.gradle"のコードを表示**
plugins {
id 'org.springframework.boot' version '2.3.3.RELEASE'
id 'io.spring.dependency-management' version '1.0.10.RELEASE'
id 'java'
}
group = 'com.example'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '11'
repositories {
mavenCentral()
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-jdbc'
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity5'
runtimeOnly 'com.h2database:h2'
testImplementation('org.springframework.boot:spring-boot-starter-test') {
exclude group: 'org.junit.vintage', module: 'junit-vintage-engine'
}
testImplementation 'org.springframework.security:spring-security-test'
}
test {
useJUnitPlatform()
}
3-3. H2データベースの設定
3-3-1. H2データベース(H2DB)の概要
H2DBはJavaで作られたインメモリデータベースです。H2DBの特徴は以下の3つなどが挙げれます。
- 軽量で導入が簡単
- 動作が高速
- OracleモードやPostgreSQLモードのようなモードを設定することで他のデータベースの挙動に切り替えることができる
インメモリデータベースのため、処理を終了すると、データも消えてしまいます。組み込みデータベースにもできますが、4. 実業務向け認証・認可機能の実装で実業務向けにデータベースの中身を改良するため、今回はインメモリデータベースとして使用します。
インメモリであるため、H2DBの初期値は処理を開始するごとに、SQL文を実行して初期値を設定しなければなりません。Spring Bootの場合、そのSQL文を「schema.sql」「data.sql」に記述することで、処理が開始するごとに、自動でSQL文を実行できます。
3-3-2. H2データベース関連のプロジェクト構成
3-3-2. application.yml(コードの追記)
H2DBの設定をapplication.ymlに記述します。
**"application.yml"のコードを表示**
##datasource
spring:
datasource:
driver-class-name: org.h2.Driver
url: jdbc:h2:mem:testdb
username: sa
password: dm
3-3-3. schema.sql(ファイル追加)
データベースのテーブル構成をSQLで記述します。
**"schema.sql"のコードを表示**
CREATE TABLE IF NOT EXISTS users (
username VARCHAR(18) NOT NULL PRIMARY KEY,
password VARCHAR(255) NOT NULL,
name VARCHAR(255) NOT NULL,
rolename VARCHAR(10) NOT NULL
);
3-3-4. data.sql(ファイル追加)
H2DBにデータを挿入します。「$2a$10$」から始まる60文字ある文字列はハッシュ化されたパスワードです。詳しくは3-7. パスワードのハッシュ化で説明しますので、ここではコピペしてください。
平文パスワード | ハッシュ化パスワード |
---|---|
admin | $2a$10$bCR1jXhdqbh1oC8ckXplxePYW5Kyb/VjN28MZx2PwXf1ybzLIFUQG |
user | $2a$10$yyT1siJCep647RT/I7KjcuUB5noFVU6RBo0FUXUJX.hb2MIlWTbDe |
**"data.sql"のコードを表示**
INSERT INTO users(username, password, name, rolename) VALUES ('admin', '$2a$10$bCR1jXhdqbh1oC8ckXplxePYW5Kyb/VjN28MZx2PwXf1ybzLIFUQG', 'admin-name', 'ADMIN');
INSERT INTO users(username, password, name, rolename) VALUES ('user' , '$2a$10$yyT1siJCep647RT/I7KjcuUB5noFVU6RBo0FUXUJX.hb2MIlWTbDe', 'user-name' , 'USER' );
3-4. アプリケーション機能の実装
3-4-1. アプリケーション機能の概略
下図のような5つのページを表示、遷移できるようなアプリケーションを作成します。
このページ遷移を実現するためのアプリケーション処理が下図の薄緑で囲まれた部分です。認証・認可処理に焦点を当てるため、アプリケーション処理はControllerとViewのみ作成します。(ServiceやDao等は作成しません。)
3-4-2. アプリケーション機能関連のプロジェクト構成
3-4-3. Controller(ファイル追加)
**"LoginController.java"のコードを表示**
package com.example.demo.controller;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
@Controller
public class LoginController {
@GetMapping("/loginForm")
String loginForm() {
return "login";
}
}
**"HomeController.java"のコードを表示**
package com.example.demo.controller;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
@Controller
public class HomeController {
@GetMapping("/home")
public String home() {
return "home";
}
}
**"AdminPageController.java"のコードを表示**
package com.example.demo.controller;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
@Controller
public class AdminPageController {
@GetMapping("/adminPage")
public String adminPage() {
return "adminPage";
}
}
**"UserPageController.java"のコードを表示**
package com.example.demo.controller;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
@Controller
public class UserPageController {
@GetMapping("/userPage")
public String userPage() {
return "userPage";
}
}
**"AccessDeniedPageController.java"のコードを表示**
package com.example.demo.controller;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
@Controller
public class AccessDeniedPageController {
@GetMapping("/accessDeniedPage")
public String accessDeniedPage() {
return "accessDeniedPage";
}
}
3-4-4. View(ファイル追加)
HTMLファイルを作成します。テンプレートエンジンとして、Thymeleafを使ったソースコードとなっています。
「templates」フォルダーがない場合は、src/main/resources下にフォルダを追加してください。
**"login.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>ログイン</title>
</head>
<body>
<h1>ログイン</h1>
<div th:if="${param.error}" > <!-- (1) リクエストパラメータの判定 -->
<div>
UserNameまたは、Passwordが違います。 <!-- (2) 認証エラーメッセージ -->
</div>
</div>
<form method="post" th:action="@{/authenticate}"> <!-- (3) ログイン処理のパスを指定 -->
<label>UserName:</label>
<input type="text" id="userName" name="userName"> <!-- (4) ユーザ名の入力部 -->
<br>
<label>Password:</label>
<input type="password" id="password" name="password"> <!-- (5) パスワードの入力部 -->
<br>
<input type="submit" id="submit" value="ログイン">
</form>
</body>
</html>
項番 | 説明 |
---|---|
(1) | リクエストパラメータに設定されたエラーメッセージの判定を行う。 3-5-6. JavaConfig(ファイル追加) "WebSecurityConfig.java"における ”failureUrl” に設定された値によって、判定処理を変更する必要があるので注意すること。 |
(2) | 認証エラー時に出力させる例外メッセージ |
(3) | formのaction属性に認証処理を行うための遷移先を指定する。 遷移先のパスは、3-5-6. JavaConfig(ファイル追加) "WebSecurityConfig.java"における "loginProcessingUrl” で指定する値と一致させる必要がある。 HTTPメソッドは、「POST」を指定すること。 今回の場合、${pageContext.request.contextPath}/authenticateにアクセスすることで認証処理が実行される。 |
(4) | 認証処理において、「ユーザ名」として扱われる要素。 name属性は、3-5-6. JavaConfig(ファイル追加) "WebSecurityConfig.java"における "usernameParameter” で指定する値と一致させる必要がある。 |
(5) | 認証処理において、「パスワード」として扱われる要素。 name属性は、3-5-6. JavaConfig(ファイル追加) "WebSecurityConfig.java"における "passwordParameter” で指定する値と一致させる必要がある。 |
**"home.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>Home</title>
</head>
<body>
<h1>Home</h1>
<form method="get" th:action="@{/adminPage}">
<input type="submit" value="AdminPageへ">
</form>
<form method="get" th:action="@{/userPage}">
<input type="submit" value="UserPageへ">
</form>
<form method="post" th:action="@{/logout}"> <!-- (1) ログアウト処理のパスを指定 -->
<input type="submit" value="ログアウト">
</form>
</body>
</html>
項番 | 説明 |
---|---|
(1) | formのaction属性にログアウト処理を実行するためのパスを指定する。 ログアウト処理のパスはSpring Securityのデフォルト値である、/logoutを指定すること。 HTTPメソッドは、「POST」を指定すること。 |
**"adminPage.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>AdminPage</title>
</head>
<body>
<h1>Admin Page</h1>
<table border="1">
<tr>
<th>項目</th>
<th>内容</th>
</tr>
<tr>
<td>UserName</td>
<td><span sec:authentication="principal.username"></span></td> <!-- (1) ユーザ名を表示 -->
</tr>
<tr>
<td>Name</td>
<td><span sec:authentication="principal.name"></span></td> <!-- (2) 名前を表示 -->
</tr>
<tr>
<td>Role</td>
<td><span sec:authentication="principal.authorities"></span></td> <!-- (3) Roleを表示 -->
</tr>
</table>
<form method="get" th:action="@{/home}">
<input type="submit" value="Homeへ">
</form>
<form method="post" th:action="@{/logout}">
<input type="submit" value="ログアウト">
</form>
</body>
</html>
項番 | 説明 |
---|---|
(1) | Thymeleafを使用して認証オブジェクトにアクセスし、UserNameを表示する。 |
(2) | Thymeleafを使用して認証オブジェクトにアクセスし、Nameを表示する。 |
(3) | Thymeleafを使用して認証オブジェクトにアクセスし、Roleを表示する。 |
**"userPage.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>UserPage</title>
</head>
<body>
<h1>User Page</h1>
<table border="1">
<tr>
<th>項目</th>
<th>内容</th>
</tr>
<tr>
<td>UserName</td>
<td><span sec:authentication="principal.username"></span></td>
</tr>
<tr>
<td>Name</td>
<td><span sec:authentication="principal.name"></span></td>
</tr>
<tr>
<td>Role</td>
<td><span sec:authentication="principal.authorities"></span></td>
</tr>
</table>
<form method="get" th:action="@{/home}">
<input type="submit" value="Homeへ">
</form>
<form method="post" th:action="@{/logout}">
<input type="submit" value="ログアウト">
</form>
</body>
</html>
**"accessDeniedPage.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>Access Denied</title>
</head>
<body>
<h1>アクセスが拒否されました</h1>
<form method="get" th:action="@{/home}">
<input type="submit" value="Homeへ">
</form>
<form method="post" th:action="@{/logout}">
<input type="submit" value="ログアウト">
</form>
</body>
</html>
3-5. 認証機能の実装
3-5-1. Spring Security 認証機能の概略
下図のピンクで囲まれた部分が認証機能をあらわします。
認証処理の流れは次のようになっています。(処理番号は上図の番号と対応)
- 「UsernamePasswordAuthenticationFilter」から「AuthenticationManager」の認証処理を呼び出す。
- 「AuthenticationManager」から、
UserDetailsService
(※1) を継承した「AccountUserDetailsService」のユーザ取得処理を呼び出す。 - 「AccountUserDetailsService」から、「Dao」を呼び出す。
- 「Dao」は、UsernameやPasswordなどDBから取得したユーザ情報を、「MyUser」というEntityインスタンスに変換して、「AccountUserDetailsService」に返す。
- 「AccountUserDetailsService」は、ユーザ情報(MyUser)を、
UserDetails
(※2) を継承した「AccountUserDetails」のインスタンスに変換して、「AuthenticationManager」に返す。 - 「AuthenticationManager」は、「AccountUserDetails」とクライアントが指定した認証情報との照合を行い、その結果を「UsernamePasswordAuthenticationFilter」に返す。
- 「UsernamePasswordAuthenticationFilter」は、「AuthenticationManager」から返却された認証結果を受け取って、認証成功 or 認証失敗のレスポンスを制御する。
(※1) UserDetailsService
認証処理で必要となる資格情報(ユーザ名とパスワード)とユーザの状態を取得する役割を担うインターフェースです。
(※2) UserDetails
資格情報とユーザの状態を提供するインターフェースで、UserDetailsService
からインスタンスが作成されます。
DBを用いて認証処理をする場合は、アプリケーションの要件に合わせて、UserDetailsServiceの実装クラス(この記事ではAccountUserDetailsService)とUserDetailsの実装クラス(この記事ではAccountUserDetails)を作成する必要があります。
3-5-2. 認証機能関連のプロジェクト構成
3-5-3. Entity(ファイル追加)
**"MyUser.java"のコードを表示**
package com.example.demo.entity;
import java.io.Serializable;
public class MyUser implements Serializable{
private String userName; // H2DBにおける、usersテーブルの"username"を格納するフィールド
private String password; // H2DBにおける、usersテーブルの"password"を格納するフィールド
private String name; // H2DBにおける、usersテーブルの"name"を格納するフィールド
private String roleName; // H2DBにおける、usersテーブルの"roleName"を格納するフィールド
/**
* getter, setter
*/
public String getUserName() {
return userName;
}
public void setUserName(String userName) {
this.userName = userName;
}
public String getPassword() {
return password;
}
public void setPassword(String password) {
this.password = password;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getRoleName() {
return roleName;
}
public void setRoleName(String roleName) {
this.roleName = roleName;
}
}
3-5-4. DAO(ファイル追加)
Spring JDBCを用いて、DBにアクセスします。
**"UserDao.java"のコードを表示**
package com.example.demo.repository;
import com.example.demo.entity.MyUser;
public interface UserDao {
MyUser findUserByUserName(String userName);
}
**"UserDaoImpl.java"のコードを表示**
package com.example.demo.repository;
import java.util.Map;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Repository;
import com.example.demo.entity.MyUser;
@Repository
public class UserDaoImpl implements UserDao {
private final JdbcTemplate jdbcTemplate;
@Autowired
public UserDaoImpl(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
/**
* userNameを検索条件にSELECT文を実行して、DBに登録されているユーザを検索する
* @param userName
* @return User
*/
@Override
public MyUser findUserByUserName(String userName) {
String sql = "SELECT username, password, name, rolename FROM users WHERE username = ?";
//ユーザを一件取得
Map<String, Object> result = jdbcTemplate.queryForMap(sql, userName);
// Entityクラス(User型)に変換
MyUser user = convMapToUser(result);
return user;
}
/**
* SQL SELECT文を実行した結果(Map<String, Object>)をUser型に変換する
* @param Map<String, Object>
* @return User
*/
private MyUser convMapToUser(Map<String, Object> map) {
MyUser user = new MyUser();
user.setUserName((String) map.get("username"));
user.setPassword((String) map.get("password"));
user.setName((String) map.get("name"));
user.setRoleName((String) map.get("rolename"));
return user;
}
}
3-5-5. Service(ファイル追加)
**"AccountUserDetailsService.java"のコードを表示**
package com.example.demo.service;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.dao.EmptyResultDataAccessException;
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 com.example.demo.entity.MyUser;
import com.example.demo.repository.UserDao;
@Service
public class AccountUserDetailsService implements UserDetailsService {
private final UserDao userDao;
@Autowired
public AccountUserDetailsService(UserDao userDao) {
this.userDao = userDao;
}
@Override
public UserDetails loadUserByUsername(String userName)
throws UsernameNotFoundException { // --- (1) データベースからアカウント情報を検索するメソッド
if (userName == null || "".equals(userName)) {
throw new UsernameNotFoundException(userName + "is not found");
}
//User一件を取得 userNameが無ければ例外発生
try {
//Userを取得
MyUser myUser = userDao.findUserByUserName(userName);
if (myUser != null) {
return new AccountUserDetails(myUser); // --- (2) UserDetailsの実装クラスを生成
} else {
throw new UsernameNotFoundException(userName + "is not found");
}
} catch (EmptyResultDataAccessException e) {
throw new UsernameNotFoundException(userName + "is not found");
}
}
}
項番 | 説明 |
---|---|
(1) | データベースからアカウント情報を検索する。アカウント情報が見つからない場合は、UsernameNotFoundExceptionを投げる |
(2) | アカウント情報が見つかった場合は、UserDetailsの実装クラス(AccountUserDetails)を生成する。 |
**"AccountUserDetails.java"のコードを表示**
package com.example.demo.service;
import java.util.Collection;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.AuthorityUtils;
import org.springframework.security.core.userdetails.UserDetails;
import com.example.demo.entity.MyUser;
public class AccountUserDetails implements UserDetails {
private final MyUser myUser;
public AccountUserDetails(MyUser myUser) {
this.myUser = myUser;
}
public MyUser getUser() { // --- (1) Entityである MyUserを返却するメソッド
return myUser;
}
public String getName() { // --- (2) nameを返却するメソッド
return this.myUser.getName();
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() { // --- (3) ユーザに与えられている権限リストを返却するメソッド
return AuthorityUtils.createAuthorityList("ROLE_" + this.myUser.getRoleName());
}
@Override
public String getPassword() { // --- (4) 登録されているパスワードを返却するメソッド
return this.myUser.getPassword();
}
@Override
public String getUsername() { // --- (5) ユーザ名を返却するメソッド
return this.myUser.getUserName();
}
@Override
public boolean isAccountNonExpired() { // --- (6) アカウントの有効期限の状態を判定するメソッド
return true;
}
@Override
public boolean isAccountNonLocked() { // --- (7) アカウントのロック状態を判定するメソッド
return true;
}
@Override
public boolean isCredentialsNonExpired() { // --- (8) 資格情報の有効期限の状態を判定するメソッド
return true;
}
@Override
public boolean isEnabled() { // --- (9) 有効なユーザかを判定するメソッド
return true;
}
}
項番 | 説明 |
---|---|
(1) | Entityである MyUserを返却するメソッド。 認証処理成功後の処理でアカウント情報にアクセスできるようにするために、getterメソッドを用意する。 |
(2) | nameを返却するメソッド。 |
(3) | ユーザに与えられている権限リストを返却するメソッド。 このメソッドは認可処理で利用する。Spring Securityの認可処理では、「ROLE_」で始まる権限情報をロールとして扱う。そのため、「ROLE_」という文字列を付加している。 |
(4) | 登録されているパスワードを返却するメソッド。 このメソッドで返却したパスワードは、クライアントから指定されたパスワードとの比較に使用される。 |
(5) | ユーザ名を返却するメソッド。 |
(6) | アカウントの有効期限の状態を判定するメソッド。 有効期限内の場合はtrueを返却し、有効期限切れの場合はfalseを返却する。 今回のプログラムでは、簡単のためにtrueのみを返却するようにしている。 |
(7) | アカウントのロック状態を判定するメソッド。 ロックされていない場合はtureを返却し、アカウントがロックされている場合はfalseを返却する。 今回のプログラムでは、簡単のためにtrueのみを返却するようにしている。 |
(8) | 資格情報の有効期限の状態を判定するメソッド。 有効期限内の場合はtrueを返却し、有効期限切れの場合はfalseを返却する。 今回のプログラムでは、簡単のためにtrueのみを返却するようにしている。 |
(9) | 有効なユーザかを判定するメソッド。 有効な場合はtrueを返却し、無効なユーザの場合falseを返却する。 今回のプログラムでは、簡単のためにtrueのみを返却するようにしている。 |
3-5-6. JavaConfig(ファイル追加)
**"WebSecurityConfig.java"のコードを表示**
package com.example.demo.config;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import com.example.demo.service.AccountUserDetailsService;
@Configuration
@EnableWebSecurity // --- (1)
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
AccountUserDetailsService userDetailsService;
PasswordEncoder passwordEncoder() {
//BCryptアルゴリズムを使用してパスワードのハッシュ化を行う
return new BCryptPasswordEncoder(); // --- (2) BCryptアルゴリズムを使用
}
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
// AuthenticationManagerBuilderに、実装したUserDetailsServiceを設定する
auth.userDetailsService(userDetailsService) // --- (3) 作成したUserDetailsServiceを設定
.passwordEncoder(passwordEncoder()); // --- (2) パスワードのハッシュ化方法を指定(BCryptアルゴリズム)
}
@Override
protected void configure(HttpSecurity http) throws Exception {
// 認可の設定
http.authorizeRequests()
.antMatchers("/loginForm").permitAll() // --- (4) /loginFormは、全ユーザからのアクセスを許可
.anyRequest().authenticated(); // --- (5) /loginForm以外は、認証を求める
// ログイン設定
http.formLogin() // --- (6) フォーム認証の有効化
.loginPage("/loginForm") // --- (7) ログインフォームを表示するパス
.loginProcessingUrl("/authenticate") // --- (8) フォーム認証処理のパス
.usernameParameter("userName") // --- (9) ユーザ名のリクエストパラメータ名
.passwordParameter("password") // --- (10) パスワードのリクエストパラメータ名
.defaultSuccessUrl("/home") // --- (11) 認証成功時に遷移するデフォルトのパス
.failureUrl("/loginForm?error=true"); // --- (12) 認証失敗時に遷移するパス
// ログアウト設定
http.logout()
.logoutSuccessUrl("/loginForm") // --- (13) ログアウト成功時に遷移するパス
.permitAll(); // --- (14) 全ユーザに対してアクセスを許可
}
}
項番 | 説明 |
---|---|
(1) |
@EnableWebSecurity を指定すると、Spring Securityを利用するために必要なコンポーネントのBean定義が自動で行われる仕組みになっている。 |
(2) | BCryptアルゴリズムを使用して、パスワードのハッシュ化をするように設定する。 |
(3) |
AuthenticationManagerBuilder に、作成したUserDetailsService を設定する。 |
(4) | /loginFormは、全ユーザからのアクセスを許可する。 |
(5) | /loginForm以外は、認証を求める。 未認証ユーザはログイン画面にリダイレクトされる。 認証済みではあるがアクセス許可されていないユーザは、アクセス拒否される。 |
(6) |
formLogin メソッドを呼び出すと、フォーム認証が有効になる。 |
(7) | ログインフォームを表示するパスを指定する。匿名ユーザが認証を必要とするページにアクセスしようとした場合、ここで指定したパスにリダイレクトする。 |
(8) | フォーム認証処理のパスを指定する。3-4-4. View(ファイル追加)のlogin.htmlにおいて、formタグのaction属性と一致させる必要がある。 |
(9) | 資格情報であるユーザ名のリクエストパラメータ名を指定する。3-4-4. View(ファイル追加)のlogin.htmlにおいて、ユーザ名を入力するinputタグのname属性と一致させる必要がある。 |
(10) | 資格情報であるパスワードのリクエストパラメータ名を指定する。3-4-4. View(ファイル追加)のlogin.htmlにおいて、パスワードを入力するinputタグのname属性と一致させる必要がある。 |
(11) | 認証成功時に遷移するデフォルトのパスを指定する。 |
(12) | 認証失敗時に遷移するパスを指定する。 |
(13) | ログアウト成功時に遷移するパスを指定する。 |
(14) | 全ユーザに、ログアウトとログアウト成功時に遷移するパスへのアクセスを許可する。 |
3-6. 認可機能の実装
3-6-1. Spring Security 認可機能の概略
下図の水色で囲まれた部分が認可機能をあらわします。
認可処理では、「FilterSecurityInterceptor」でリクエストされたURLがアクセス許可されているかを判定します。 `@PreAuthorize`を用いて、各ユーザのアクセス許可を設定します。`@PreAuthorize`はControllerやMethodごとに記述できます。-
アクセス許可された場合
次のフィルタに処理を流します。 -
アクセス拒否された場合
「FilterSecurityInterceptor」はAccessDeniedException
という例外を投げます。
「ExceptionTranslationFilter」で例外をキャッチして、未認証ユーザーからのアクセスの場合は認証を促すレスポンス(ログイン画面への遷移)、認証済みのユーザーからのアクセスの場合は認可エラーを通知するレスポンス(アクセス拒否画面への遷移)を返却します。
3-6-2. 認可機能関連のプロジェクト構成
コードを追記するファイルを青で強調しています。
3-6-3. JavaConfig(コードの追記)
許可されていないページにアクセスしようとすると、アクセスが拒否されたことを示すページに遷移するように設定します。
**"WebSecurityConfig.java"のコードを表示**
/* 省略 */
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true) // 追記 --- (1) メソッド認可処理を有効化
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
/* 省略 */
protected void configure(HttpSecurity http) throws Exception {
// 認可の設定
http.exceptionHandling() // 追記
.accessDeniedPage("/accessDeniedPage") // 追記 --- (2) アクセス拒否された時に遷移するパス
.and() // 追記
.authorizeRequests()
.antMatchers("/loginForm").permitAll()
.anyRequest().authenticated();
/* 省略 */
項番 | 説明 |
---|---|
(1) |
@PreAuthorize や@PostAuthorize によるメソッド認可処理を有効化する。 |
(2) | アクセス拒否(認可エラー)された時に、遷移するパスを指定する。 |
3-6-4. Controller(コードの追記)
各Methodに@PreAuthorize
を記述して、アクセス権限を設定します。
**"AdminPageController.java"のコードを表示**
/* 省略 */
@GetMapping("/adminPage")
@PreAuthorize("hasRole('ROLE_ADMIN')") // 追記 --- (1) ROLE_ADMINのユーザのみアクセスを許可
public String adminPage() {
/* 省略 */
項目 | 説明 |
---|---|
(1) | 「ROLE_ADMIN」を持っているユーザのみアクセスを許可する。 |
**"UserPageController.java"のコードを表示**
/* 省略 */
@GetMapping("/userPage")
@PreAuthorize("hasRole('ROLE_USER')") // 追記 --- (1) ROLE_USERのユーザのみアクセスを許可
public String userPage() {
/* 省略 */
項目 | 説明 |
---|---|
(1) | 「ROLE_USER」を持っているユーザのみアクセスを許可する。 |
3-6-5. View(コードの追記)
「home」ページにおいて、「adminPageへ」「userPageへ」ボタンがログイン情報に合わせて表示されるようにします。
**"home.html"のコードを表示**
<!-- 省略 -->
<body>
<h1>Home</h1>
<div sec:authorize="hasRole('ADMIN')"> <!-- 追記 (1) ROLE_ADMINのユーザのみ表示 -->
<form method="get" th:action="@{/adminPage}">
<input type="submit" value="AdminPageへ">
</form>
</div>
<div sec:authorize="hasRole('USER')"> <!-- 追記 (2) ROLE_USERのユーザのみ表示 -->
<form method="get" th:action="@{/userPage}">
<input type="submit" value="UserPageへ">
</form>
</div>
<div sec:authorize="isAuthenticated()"> <!-- 追記 (3) ログイン済みのユーザのみ表示 -->
<form method="post" th:action="@{/logout}">
<input type="submit" value="ログアウト">
</form>
</div>
</body>
</html>
項目 | 説明 |
---|---|
(1) | 「ROLE_ADMIN」を持っているユーザのみ表示される。 |
(2) | 「ROLE_USER」を持っているユーザのみ表示される。 |
(3) | ログインしている(認証済みの)ユーザのみ表示される。 |
3-7. パスワードのハッシュ化
3-7-1. ハッシュ化の概略
ハッシュ化とは、文字列を特定のアルゴリズム(ハッシュ関数)を用いて、別の値(ハッシュ値)に変換することです。パスワードの保管などでよく用いられます。暗号化とハッシュ化の違いは、元の値に戻せる(復号できる)かどうかです。暗号化された値は復号できますが、ハッシュ化された値は復号できません。そのため、第三者がハッシュ値から元のデータを割り出すことは極めて困難です。
Spring Securityでは、ハッシュ化に関する要件が特にない場合、BCryptPasswordEncoderを使用することを推奨されています。
ハッシュ化について、詳しく知りたい方は、以下のページを参考にしてください。
【基礎知識】暗号化とは?ハッシュ化とは?パスワードの漏洩を防ぐには?
3-7-2. パスワードのハッシュ化プログラム作成
下記のプログラムは新規に作成したプロジェクトで作成する方が良いと思います。(新プロジェクトでも、Spring Securityを依存関係に設定する必要があります。)
既存のプロジェクトでもプログラムは作成できますが、同一システム内にmain関数が複数できるため、プロジェクトを新規に作成したほうが良いと作者は思います。
rawPasswordの値(コード内では、admin)をパスワードに設定したい値に変更してください。
**"GeneratePassword.java"のコードを表示**
package com.example.demo.password;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
public class GeneratePassword {
public static void main(String[] args) {
// ハッシュ化したいパスワードを入力
String rawPassword = "admin";
// パスワードをハッシュ化
String password = getEncodePassword(rawPassword);
// ハッシュ化された値を表示
System.out.println(password);
}
private static String getEncodePassword(String rawPassword) {
BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder ();
return passwordEncoder.encode(rawPassword);
}
}
3-7-3. パスワードのハッシュ化を実際にしてみる
3-7-2. パスワードのハッシュ化プログラム作成で作成したプログラムをJavaアプリケーションで実行します。
コンソールに表示された値がハッシュ化されたパスワードです。
4. 実業務向け認証・認可機能の実装
4-1. 実業務向け認証機能
多要素認証(MFA:Multi-Factor Authentication)の実装方法を紹介します。近年、セキュリティの向上のため、MFAを導入するシステムが多くなってきています。また、弊社の多くのシステムにMFAが導入されているため、MFAの実装方法を実業務向け実装として記事に書きたいと思います。
MFAの実装は少し長くなるので、下記の記事で詳しく説明します。パスワードとワンタイムパスワードを用いたMFAの実装記事となっています。
現在作成中(もう少しお待ち下さい)
4-2. 実業務向け認可機能
社内で実際のシステムについてヒアリングした結果、ひとつとして「アクセス制御情報をDBに格納して認可処理を行う」が挙げられました。(3. 簡易な認証・認可機能の実装では、アノテーションを用いて認可処理をおこなっていました。)
このDBに格納したアクセス制御情報による認可処理の実装方法を実業務向け実装として記事に書きたいと思います。
4-2-1. 実装するメリット
簡易実装では、@PreAuthorize
を用いて、認可機能を実装していました。しかし、実際のシステムでは、DBを用いて認可機能を実装することが多いようです。その理由は、Roleに対してアクセスを許可するページの管理がしやすいということです。
@PreAuthorize
を使用した場合、ControllerやMethodごとにアノテーションを付与するため、様々なファイルに分散され、どのRoleにどのページがアクセス許可されているのかわかりづらくなります。これは、後にコードを読み直すときやシステムを改修するときに大変見通しが悪く、効率が落ちる原因になります。
それに対して、DBで下記のようなテーブルを作成し、アクセス制御情報をDBのみで管理させることで、Roleとアクセスを許可するページの管理がずっと楽になります。
今回使用するテーブルの一部を例示します。Roleの*
はワイルドカードで、全てのRole(認証されていないユーザも含む)に対して、アクセス許可をするという意味になります。また、ADMIN
Roleには/adminPage
を、USER
Roleには/userPage
をアクセス許可することを意味しています。
Role | Authorize Page |
---|---|
* | /loginForm |
ADMIN | /adminPage |
USER | /userPage |
4-2-2. 実装する実業務向け認可機能の概要
ページの遷移については、簡易実装の時と変わらず、下図のようなページ遷移になります。
下図の青色で囲まれた部分が実装する実業務向け認可処理をあらわします。認可処理における投票処理やService、DAOを作成します。
クライアントのユーザ情報(Role)がアクセスしようとしているページ(URL)を許可されているか、データベースに問い合わせることで、認可機能を実現します。
認可処理の流れは次のようになっています。(処理番号は上図の番号と対応)
- 「FilterSecurityInterceptor」は、
AccessDecisionManager
**(※1)**を実装した「AffirmativeBased」の認可処理を呼び出す。 - 「AffirmativeBased」は、
AccessDecisionVoter
**(※2)**を実装した「MyVoter」の投票処理を呼び出す。
(「AffirmativeBased」では、AccessDecisionVoter
の実装クラスを複数設定し、それぞれの投票処理を呼び出すことが可能であるが、今回の実装は「MyVoter」のみ設定している。) - 「MyVoter」は、クライアントのRoleとリクエストされたURLを取得し、アクセス許可の判定処理を行う「AuthorizationService」を呼び出す。
- 「AuthorizationService」は、「Dao」を呼び出す。
- 「Dao」は、クライアントのRoleとリクエストされたURLの組み合わせが存在するかDBに問い合わせて、存在したら「AccessAuthorization」というEntityインスタンスに変換して、「AuthorizationService」に返す。
- 「AuthorizationService」は、アクセス許可情報(AccessAuthorization)が存在したらアクセス許可(
true
)、存在しなかったらアクセス拒否(false
)を「MyVoter」に返す。 - 「MyVoter」は、「MyVoter」からアクセス許可(
true
)が返ってきたら付与(ACCESS_GRANTED
)を、アクセス拒否(false
)が返ってきたら拒否(ACCESS_DENIED
)を「AffirmativeBased」に返す。(詳細は**(※2)**) - 「AffirmativeBased」は、「MyVoter」の投票結果が付与(
ACCESS_GRANTED
)であればアクセス許可を返却し、拒否(ACCESS_DENIED
)であればAccessDeniedException
という例外を投げる。(詳細は**(※1)**) - 「FilterSecurityInterceptor」は、「AffirmativeBased」で、アクセス許可されたら次のフィルタに処理を流し、アクセス拒否されたら
AccessDeniedException
を投げる。AccessDeniedException
は「ExceptionTranslationFilter」でキャッチされる。
(※1) AccessDecisionManager
アクセスしようとしたリソースに対して、アクセス権があるかチェックを行うインターフェースです。
Spring Securityが提供する実装クラスは3種類存在しますが、いずれもAccessDecisionVoter
というインターフェースのvote
メソッドを呼び出してアクセス権を付与するか否かを判定させます。AccessDecisionVoter
は「付与」「拒否」「棄権」のいずれかを投票し、AccessDecisionManager
の実装クラスが投票結果を集約して最終的なアクセス権を判断します。 アクセス権がないと判断した場合は、AccessDeniedException
を発生させアクセスを拒否します。
下の表は、Spring Securityが提供する実装クラスを示しています。
実装クラス | 説明 |
---|---|
AffirmativeBased |
AccessDecisionVoter に投票させ、「付与」が1件投票された時点でアクセス権を与える実装クラス。デフォルトで使用される実装クラス。 |
ConsensusBased | 全てのAccessDecisionVoter に投票させ、「付与」の投票数が多い場合にアクセス権を与える実装クラス。 |
UnanimousBased |
AccessDecisionVoter に投票させ、「拒否」が1件投票された時点でアクセス権を与えない実装クラス。 |
(※2) AccessDecisionVoter
AccessDecisionVoter
は、アクセスしようとしたリソースに指定されているアクセスポリシーを参照してアクセス権を付与するかを投票するためのインタフェースです。AccessDecisionVoter
は「付与」「拒否」「棄権」のいずれかを投票します。
下記の表は、Spring Securityが提供する主な実装クラスを示しています。
実装クラス | 説明 |
---|---|
WebExpressionVoter | Spring Expression Language (SpEL)を使用して、ユーザの認証情報(Authentication )とリクエスト情報(HttpServletRequest )を参照して投票を行う実装クラス。 |
RoleVoter | ユーザが持つRoleを参照して投票を行う実装クラス。 |
RoleHierarchyVoter | ユーザが持つ階層化されたRoleを参照して投票を行う実装クラス。 |
AuthenticatedVoter | 認証状態を参照して投票を行う実装クラス。 |
今回の実装では、AccessDecisionVoter
を実装した「MyVoter」というクラスを新たに作成し、その「MyVoter」のみを、AffirmativeBased
が呼び出す投票処理に設定します。
4-2-3. 実業務向け認可機能のプロジェクト構成
3. 簡易な認証・認可機能の実装で実装したものをベースに、追加するファイルは赤、コードを追記or修正するファイルは青で強調しています。
4-2-4. H2データベース(コード追記)
**"schema.sql"のコードを表示**
/* 以下のコードをファイルの末尾に追記 */
CREATE TABLE IF NOT EXISTS access_authorization (
rolename VARCHAR(10) NOT NULL,
uri VARCHAR(255) NOT NULL,
PRIMARY KEY(rolename, uri)
);
**"data.sql"のコードを表示**
/* 以下のコードをファイルの末尾に追記 */
/* 全Roleのアクセス許可*/
INSERT INTO access_authorization(rolename, uri) VALUES ('*', '/loginForm');
INSERT INTO access_authorization(rolename, uri) VALUES ('*', '/accessDeniedPage');
INSERT INTO access_authorization(rolename, uri) VALUES ('*', '/logout');
/* ADMIN Roleのアクセス許可 */
INSERT INTO access_authorization(rolename, uri) VALUES ('ADMIN', '/home');
INSERT INTO access_authorization(rolename, uri) VALUES ('ADMIN', '/adminPage');
/* USER Roleのアクセス許可 */
INSERT INTO access_authorization(rolename, uri) VALUES ('USER', '/home');
INSERT INTO access_authorization(rolename, uri) VALUES ('USER', '/userPage');
roleNameが"*"になっているのは、ワイルドカードで、すべてのRoleにアクセスを許可することを意味しています。
4-2-5. Controller(コード修正)
認可の設定をDBで行うため、不要な`@PreAuthorize`をコメントアウトする。**"AdminPageController.java"のコードを表示**
/* 省略 */
@GetMapping("/adminPage")
// @PreAuthorize("hasRole('ROLE_ADMIN')") コメントアウト
public String adminPage() {
/* 省略 */
**"UserPageController.java"のコードを表示**
/* 省略 */
@GetMapping("/userPage")
// @PreAuthorize("hasRole('ROLE_USER')") コメントアウト
public String userPage() {
/* 省略 */
4-2-6. Entity(ファイル追加)
**"AccessAuthorization.java"のコードを表示**
package com.example.demo.entity;
import java.io.Serializable;
public class AccessAuthorization implements Serializable {
String roleName; // H2DBにおける、access_authorizationテーブルの"rolename"を格納するフィールド
String uri; // H2DBにおける、access_authorizationテーブルの"uri"を格納するフィールド
/**
* getter, setter
*/
public String getRoleName() {
return roleName;
}
public void setRoleName(String roleName) {
this.roleName = roleName;
}
public String getUri() {
return uri;
}
public void setUri(String uri) {
this.uri = uri;
}
}
4-2-7. DAO(ファイル追加)
Spring JDBCを用いて、DBにアクセスします。
**"AccessAuthorizationDao.java"のコードを表示**
package com.example.demo.repository;
import com.example.demo.entity.AccessAuthorization;
public interface AccessAuthorizationDao {
AccessAuthorization find(String roleName, String uri);
}
**"AccessAuthorizationDaoImpl.java"のコードを表示**
package com.example.demo.repository;
import java.util.Map;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Repository;
import com.example.demo.entity.AccessAuthorization;
@Repository
public class AccessAuthorizationDaoImpl implements AccessAuthorizationDao {
private final JdbcTemplate jdbcTemplate;
@Autowired
public AccessAuthorizationDaoImpl(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
/**
* roleNameとuriを検索条件にSELECT文を実行して、DBに登録されているか検索する
* @param roleName
* @param uri
* @return AccessAuthorization
*/
@Override
public AccessAuthorization find(String roleName, String uri) {
String sql = "SELECT rolename, uri FROM access_authorization WHERE rolename = ? AND uri = ?";
//ユーザを一件取得
Map<String, Object> result = jdbcTemplate.queryForMap(sql, roleName, uri);
// Entityクラス(User型)に変換
AccessAuthorization auth = convMapToAccessAuthorization(result);
return auth;
}
/**
* SQL SELECT文を実行した結果(Map<String, Object>)をAccessAuthorization型に変換する
* @param Map<String, Object>
* @return AccessAuthorization
*/
private AccessAuthorization convMapToAccessAuthorization(Map<String, Object> map) {
AccessAuthorization auth = new AccessAuthorization();
auth.setRoleName((String) map.get("rolename"));
auth.setUri((String) map.get("uri"));
return auth;
}
}
4-2-8. Service(ファイル追加)
**"AuthorizationService.java"のコードを表示**
package com.example.demo.service;
public interface AuthorizationService {
boolean isAuthorized(String roleName, String uri);
}
**"AuthorizationServiceImpl.java"のコードを表示**
package com.example.demo.service;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.dao.EmptyResultDataAccessException;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import com.example.demo.entity.AccessAuthorization;
import com.example.demo.repository.AccessAuthorizationDao;
@Service
public class AuthorizationServiceImpl implements AuthorizationService {
private final AccessAuthorizationDao authDao;
@Autowired
public AuthorizationServiceImpl(AccessAuthorizationDao authDao) {
this.authDao = authDao;
}
/**
* 引数に渡された、RoleNameとURIの組み合わせがアクセス許可されているか判定する。
* @param roleName
* @param uri
* @return boolean
*/
@Override
public boolean isAuthorized(String roleName, String uri) { // ---(1) アクセス許可されているか判定するメソッド
if (StringUtils.isEmpty(roleName)) {
throw new IllegalArgumentException("RoleNameが空です。");
}
if (StringUtils.isEmpty(uri)) {
throw new IllegalArgumentException("URIが空です。");
}
//AccessAuthorization一件を取得 AccessAuthorizationが無ければ例外発生
try {
AccessAuthorization auth = authDao.find(roleName, uri); // ---(2) AccessAuthorizationインスタンスを取得
if (auth != null) { // ---(3) アクセス許可
return true;
} else { // ---(4) アクセス拒否
return false;
}
} catch (EmptyResultDataAccessException e) { // ---(5) アクセス拒否
return false;
}
}
}
項目 | 説明 |
---|---|
(1) | ユーザのroleNameとアクセスするパス(URI)が、アクセス許可されているかどうか判定するメソッド。 |
(2) | roleNameとURIをDaoに渡し、EntityであるAccessAuthorization のインスタンスを取得する。 |
(3) |
AccessAuthorization のインスタンスが取得できたら、アクセスが許可されていると判断し、true を返す。 |
(4) | DaoからAccessAuthorization のインスタンスが取得できなかったり、null が返ってきた場合はアクセスが拒否されていると判断し、false を返す。 |
4-2-9. Voter(ファイル追加)
**"MyVoter.java"のコードを表示**
package com.example.demo.voter;
import java.util.Collection;
import javax.servlet.http.HttpServletRequest;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.access.AccessDecisionVoter;
import org.springframework.security.access.ConfigAttribute;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.FilterInvocation;
import org.springframework.stereotype.Component;
import com.example.demo.service.AccountUserDetails;
import com.example.demo.service.AuthorizationService;
@Component
public class MyVoter implements AccessDecisionVoter<FilterInvocation> {
private final AuthorizationService authorizationService;
@Autowired
public MyVoter(AuthorizationService authorizationService) {
this.authorizationService = authorizationService;
}
@Override
public boolean supports(ConfigAttribute attribute) { // ---(1) 投票処理が必要か不要か判定するメソッド
return true;
}
@Override
public boolean supports(Class<?> clazz) { // ---(1) 投票処理が必要か不要か判定するメソッド
return true;
}
@Override
public int vote(Authentication authentication, FilterInvocation filterInvocation,
Collection<ConfigAttribute> attributes) { // ---(2) アクセス権を付与するかどうか投票するメソッド
HttpServletRequest request = filterInvocation.getHttpRequest(); // --- (3) HttpServletRequestの取得
String uri = request.getRequestURI(); // --- (4) リクエストからURIを取得
if(authorizationService.isAuthorized("*", uri)) { // --- (5) 全てのRoleにアクセス許可されているか判定
return ACCESS_GRANTED;
}
Object principal = authentication.getPrincipal(); // --- (6) ユーザの識別情報の取得
if (!principal.getClass().equals(AccountUserDetails.class)) { // ---(7) 取得した識別情報がAccountUserDetailsかどうか判定
return ACCESS_DENIED;
}
String roleName = ((AccountUserDetails) principal).getUser().getRoleName(); // ---(8) ユーザのRoleの取得
if(authorizationService.isAuthorized(roleName, uri)) { // ---(9) 取得したRoleがアクセス許可されているか判定
return ACCESS_GRANTED;
}
return ACCESS_DENIED;
}
}
項番 | 説明 |
---|---|
(1) | 引数の値を参照して、投票処理が必要か不要かを判定するメソッド |
(2) | アクセス権を付与するかどうか投票するメソッド (詳細は、4-2-2. 実装する実業務向け認可機能の概要) |
(3) |
HttpServletRequest を取得する。FilterInvocation は、HttpServletRequest やHttpServletRequest などのHTTPフィルターに関連付けられたオブジェクトを保持する。 |
(4) |
HttpServletRequest からアクセスしようとしているURIを取得する。 |
(5) | 取得したURIが全てのRoleにアクセス許可されているか判定する。 |
(6) | アクセスしてきたユーザの識別情報を取得する |
(7) | 取得した識別情報がAccountUserDetails クラスかどうか判定する。未認証の場合、識別情報がStringクラスで取得されるため、この判定処理を行わないとエラーが発生してしまう。 |
(8) |
AccountUserDetails のインスタンスから、ユーザのRoleを取得する。 |
(9) | ユーザのRoleとアクセスしようとするパスが、アクセス許可されているか判定する。 |
4-2-10. JavaConfig(コード修正)
作成した「MyVoter」がアクセス権の投票を行うように設定します。
**"WebSecurityConfig.java"のコードを表示**
/* 省略 */
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
AccountUserDetailsService userDetailsService;
@Autowired // 追記
AccessDecisionVoter<FilterInvocation> myVoter; // 追記
public AccessDecisionManager createAccessDecisionManager() { // 追記
return new AffirmativeBased(Arrays.asList(myVoter)); // 追記 ---(1) 認可処理はAffirmativeBased、投票処理はMyVoterを使用する。
} // 追記
/* 省略 */
@Override
protected void configure(HttpSecurity http) throws Exception {
// 認可の設定
http.exceptionHandling()
.accessDeniedPage("/accessDeniedPage")
.and()
.authorizeRequests()
.antMatchers("/**").authenticated() // 修正
.accessDecisionManager(createAccessDecisionManager()); // 追記 ---(2) すべてのアクセスにおいて、認可処理の適用
/* 省略 */
項番 | 説明 |
---|---|
(1) |
AccessDecisionManager の実装クラスとして、AffirmativeBased を適用する。AccessDecisionVoter の実装クラスとして、MyVoter を適用する。AffirmativeBased のインスタンス生成時に、AccessDecisionVoter のインスタンスを配列で渡すことで、投票処理を複数設定できる。 |
(2) | すべてのアクセスに、認可処理(MyVoter の投票処理)が実行されるように設定する。 |
5. まとめ
昨今、セキュリティ問題について頻繁にニュースで取り上げられられています。そのため、Webアプリケーションには、セキュリティ対策を必ず導入しましょう。
Spring Frameworkを使ってWebアプリケーションを開発している場合、Spring Securityを活用してセキュリティ対策すべきです。自作でセキュリティ対策するより、簡単かつ堅牢なセキュリティ対策を導入することができます。
一方、実用を考えると、認可処理でDBを参照したい等の要件でSpring Securityを一部拡張するような実装も必要になりますが、拡張する方法をこの記事で少し示すことができたと思います。「スタンダードなプロダクトに立脚した上で、業務や運用上必要な追加機能はその上に実装する。」という考え方が実業務においては重要であり、ヒアリングおよび記事の執筆を通して、作者自身学ぶことができました。
今回の記事では、セキュリティ対策の基本機能である「認証機能」と「認可機能」をSpring Securityで実装しました。その他のセキュリティ対策機能も、Spring Securityでは提供されています。まだまだ知らないことが多くあるので、学習していきたいです。
おまけ
隣の部の新人(筆者の同期)が記載した記事です。
SSO(シングルサインオン)を導入してみたいという方は、下記の記事をぜひ読んでみてください。KeycloakとAzureADの連携手順を説明しています。
Keycloakを用いて外部ID連携を試してみる