ありがとうございます。
ここから 第10段階:Spring Security 6 認証・認可設計 に進みます。
今回の教材管理システムでは、単純なログインだけではなく、以下の権限制御が必要になります。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19 | 管理者
├ 全教材参照
├ 教材審査
├ 公開承認
└ 全ユーザー管理
教師
├ 自分の教材作成
├ 自分の教材編集
├ 審査申請
└ 他教師教材は編集不可
メンター
├ 将来的に学習状況参照
└ 質問対応
受講者
├ 公開教材閲覧
└ 学習履歴登録
|
第10段階の構成
作成するクラス:
| security
├ SecurityConfig.java
├ CustomUserDetails.java
├ CustomUserDetailsService.java
├ LoginUser.java
└ PasswordConfig.java
|
1. Spring Security依存関係
Gradle:
| dependencies {
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity6'
}
|
2. SecurityConfig
Spring Security 6では、
WebSecurityConfigurerAdapter
は使用しません。
Bean方式で設定します。
SecurityConfig.java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66 | package com.example.lms.security;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
@EnableMethodSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final CustomUserDetailsService userDetailsService;
@Bean
public SecurityFilterChain securityFilterChain(
HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers(
"/css/**",
"/js/**"
).permitAll()
.requestMatchers(
"/admin/**"
)
.hasRole("ADMIN")
.requestMatchers(
"/teacher/**"
)
.hasRole("TEACHER")
.requestMatchers(
"/student/**"
)
.hasAnyRole(
"STUDENT",
"TEACHER",
"ADMIN"
)
.anyRequest()
.authenticated()
)
.formLogin(login -> login
.loginPage("/login")
.defaultSuccessUrl(
"/dashboard",
true
)
.permitAll()
)
.logout(logout -> logout
.logoutSuccessUrl("/login")
);
return http.build();
}
}
|
3. PasswordEncoder
パスワードは必ずBCrypt化します。
PasswordConfig.java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17 | package com.example.lms.security;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
@Configuration
public class PasswordConfig {
@Bean
public PasswordEncoder passwordEncoder(){
return new BCryptPasswordEncoder();
}
}
|
4. User Entityとの連携
前回作成したUser:
| users
id
username
email
password
|
をSpring Security用に変換します。
5. CustomUserDetails
Spring Securityが利用するユーザー情報です。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63 | package com.example.lms.security;
import com.example.lms.entity.user.User;
import lombok.Getter;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import java.util.stream.Collectors;
@Getter
public class CustomUserDetails implements UserDetails {
private final User user;
public CustomUserDetails(User user){
this.user = user;
}
@Override
public java.util.Collection<
? extends org.springframework.security.core.GrantedAuthority>
getAuthorities(){
return user.getRoles()
.stream()
.map(role ->
new SimpleGrantedAuthority(
"ROLE_" + role.getCode()
)
)
.collect(
Collectors.toSet()
);
}
@Override
public String getPassword(){
return user.getPassword();
}
@Override
public String getUsername(){
return user.getEmail();
}
@Override
public boolean isEnabled(){
return user.isEnabled();
}
}
|
6. UserDetailsService
DBからユーザーを取得します。
CustomUserDetailsService.java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33 | package com.example.lms.security;
import com.example.lms.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
public class CustomUserDetailsService
implements UserDetailsService {
private final UserRepository userRepository;
@Override
public UserDetails loadUserByUsername(
String email){
return userRepository
.findByEmail(email)
.map(CustomUserDetails::new)
.orElseThrow(
() ->
new UsernameNotFoundException(
"ユーザーが存在しません"
)
);
}
}
|
7. UserRepository
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15 | package com.example.lms.repository;
import com.example.lms.entity.user.User;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface UserRepository
extends JpaRepository<User,Long>{
Optional<User> findByEmail(
String email
);
}
|
8. Login画面
配置:
login.html
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69 | <!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<title>
ログイン
</title>
<link
href="/css/bootstrap.min.css"
rel="stylesheet">
</head>
<body>
<div class="container mt-5">
<h2>
教材管理システム
</h2>
<form method="post"
th:action="@{/login}">
<div class="mb-3">
<label>
メールアドレス
</label>
<input
class="form-control"
name="username"
type="email">
</div>
<div class="mb-3">
<label>
パスワード
</label>
<input
class="form-control"
name="password"
type="password">
</div>
<button
class="btn btn-primary">
ログイン
</button>
</form>
</div>
</body>
</html>
|
9. LoginController
1
2
3
4
5
6
7
8
9
10
11
12 | @Controller
public class LoginController {
@GetMapping("/login")
public String login(){
return "login";
}
}
|
10. ログインユーザー取得
ControllerやServiceで、
現在ログイン中の教師を取得します。
LoginUser.java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29 | package com.example.lms.security;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Component;
@Component
@RequiredArgsConstructor
public class LoginUser {
public User get(){
Authentication authentication =
SecurityContextHolder
.getContext()
.getAuthentication();
CustomUserDetails details =
(CustomUserDetails)
authentication.getPrincipal();
return details.getUser();
}
}
|
利用例:
1
2
3
4
5
6
7
8
9
10
11
12 | @PostMapping("/teacher/content/create")
public String create(){
User teacher =
loginUser.get();
service.create(
teacher
);
return "redirect:/teacher/content";
}
|
11. 教師の教材編集権限制御
重要部分です。
URL制御だけでは不足します。
理由:
| 教師A
/teacher/content/1/edit
↓
教師B教材
|
を防ぐ必要があります。
Service側チェック
1
2
3
4
5
6
7
8
9
10
11
12
13
14 | private void checkOwner(
Content content,
User loginUser){
if(!content.getAuthor()
.getId()
.equals(loginUser.getId())){
throw new AccessDeniedException(
"編集できません"
);
}
}
|
12. Method Security利用
例えば:
管理者承認:
| @PreAuthorize(
"hasRole('ADMIN')")
@Transactional
public void approve(
Long versionId){
}
|
教師登録:
| @PreAuthorize(
"hasRole('TEACHER')")
@Transactional
public Long create(){
}
|
13. Thymeleaf権限制御
例:
教師だけ表示。
| <div sec:authorize="hasRole('TEACHER')">
<a href="/teacher/content/create"
class="btn btn-primary">
教材作成
</a>
</div>
|
管理者:
| <div sec:authorize="hasRole('ADMIN')">
<a href="/admin/approval">
審査管理
</a>
</div>
|
14. ログイン後リダイレクト
ロール別に変更します。
例:
| AuthenticationSuccessHandler
|
を追加します。
結果:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19 | ADMIN
↓
/admin
TEACHER
↓
/teacher
STUDENT
↓
/student
|
15. 現在の認証フロー
完成形:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34 | ブラウザ
↓
/login
↓
Spring Security
↓
CustomUserDetailsService
↓
users
roles
↓
Authentication
↓
Controller
↓
Service
↓
業務処理
|
第10段階終了時点
完成したもの:
✅ ログイン
✅ BCryptパスワード
✅ Role認可
✅ 教師・管理者画面分離
✅ ログインユーザー取得
✅ 教材所有者チェック設計
次の 第11段階 では、教材作成機能を実装します。
内容:
-
教材登録Form設計
-
Bean Validation
-
教材タイプ別入力画面
-
動画登録処理
-
Markdown登録処理
-
クイズ登録処理(複数正解対応)
-
ファイルアップロード処理
-
Cloudflare R2保存設計
-
ContentVersion発行処理
ここから実際に教師が教材を作成できる機能になります。