實際要來看一下我們要使用個人的使用者資訊資料表要如何串接,因為 Security 提供很多客製化的介面所以需要實作許多特殊的物件就會讓流程蠻複雜的,整體來說可以先有個概念,我們原本需要透過 dao 來和 DB 溝通的部分需要改成 Security 框架的模式,原本可以由 Spring Data JPA 直接取得 User 的物件回傳,但因為 Security 將使用者資訊會封裝成 UserDetails 這個物件來回傳,必須要透過 UserDetailsService 這邊來取得這個物件,所以會想辦法把這些東西去實做出來。

資料庫是串接 MySQL ,相關配置可以參考 Spring Data JPA (1)基礎應用架構 引入相關 Dependency

資料庫架構 Database Schema

提供 SQL 給大家參考

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
CREATE TABLE users
(
id BIGINT PRIMARY KEY AUTO_INCREMENT,
username VARCHAR(50) NOT NULL UNIQUE,
email VARCHAR(100) NOT NULL UNIQUE,
password VARCHAR(255) NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);

CREATE TABLE roles
(
id BIGINT PRIMARY KEY AUTO_INCREMENT,
role_name VARCHAR(255) NOT NULL UNIQUE
);

CREATE TABLE user_roles
(
user_id BIGINT NOT NULL,
role_id BIGINT NOT NULL,
PRIMARY KEY (user_id, role_id),
FOREIGN KEY (user_id) REFERENCES users (id),
FOREIGN KEY (role_id) REFERENCES roles (id)
);

Config 配置

延續前面文章的配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@EnableWebSecurity
@Configuration
public class SecurityConfig {

@Bean
public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception {
return httpSecurity
.csrf(customizer -> customizer.disable())
.authorizeHttpRequests((registry) -> registry
.requestMatchers(HttpMethod.POST, "/register", "/login").permitAll()
.requestMatchers(HttpMethod.GET, "/error", "/api/products/**").permitAll()
.requestMatchers(HttpMethod.GET, "/checkAuthentication").hasAnyAuthority("ROLE_BUYER", "ROLE_SELLER", "ROLE_ADMIN")
.requestMatchers("/api/users/**").hasAuthority("ROLE_ADMIN")
.anyRequest().authenticated()
)
.formLogin(Customizer.withDefaults())
.build();
}

建 User, Role , UserRole, UserRepository

權限部分需要在 users 表裡面多一個 role 的欄位來進行多對多關聯,並且建立 roles 表和 user_roles 表

User Entity

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
@Entity
@Data
@Table(name = "users")
public class User extends BaseEntity{

@Column(nullable = false)
private String username;

@Column(nullable = false)
private String password;

@Column(nullable = false)
private String email;

@ManyToMany(fetch = FetchType.EAGER)
@JoinTable(
name = "user_roles",
joinColumns = @JoinColumn(name = "user_id"),
inverseJoinColumns = @JoinColumn(name = "role_id"),
uniqueConstraints = @UniqueConstraint(columnNames = {"user_id", "role_id"})
)
private Set<Role> roles = new HashSet<>();

}

Role Entity

roles 表內 roleName 的欄位用 Enum 作為資料類型,所以另外有 UserRole 的 Enum

1
2
3
4
5
6
7
8
9
10
11
12
13
@Data
@Entity
@Table(name = "roles")
public class Role {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Integer id;

@Enumerated(EnumType.STRING)
@Column(nullable = false, unique = true)
private UserRole roleName;
}

UserRole Enum

三種權限分別是 ADMIN, BUYER, SELLER 分別是管理者、買家、商家。

1
2
3
4
5
6
7
package com.oseanchen.crudproject.model;

public enum UserRole {
ROLE_ADMIN,
ROLE_BUYER,
ROLE_SELLER
}

UserPricipal 實作 (Custom UserDetails)

啟動程式時,Spring Security 會檢查專案中是否有 UserDetailsService 。如果沒有,則自動建立一個前一篇有提到的預設管理元件 InMemoryUserDetailsManager ,並提供預設 user 然後把密碼印在 console 。如果我們自行提供 UserDetailsService ,則 Security 就會自動採用。

把我們 User Entity 轉成 Security 的 UserDetails 介面,我們取名 UserPrincipal 來稱呼你也可以自訂自己的 UserDetails 名稱,由於是介面所以它所提供的方法都要實做一遍。

裡面有幾個主要的就是 getUsername, getPassword, getAuthorities ,讓我們可以拿到我們要的 User 資訊,其他的設置主要關於帳號是否到期、鎖定、密碼過期、啟用等等,可以依據你的需求配置紀錄相關資訊,讓 Spring Security 可以進行驗證,就算帳號密碼正確也不一定可以通過認證。這邊先進行簡單的設計都預設為 true 通過驗證

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
public class UserPrincipal implements UserDetails {

private User user;

public UserPrincipal(User user) {
this.user = user;
}

@Override
public Collection<? extends GrantedAuthority> getAuthorities() {

Set<GrantedAuthority> authorities = user.getRoles().stream()
.map(role -> new SimpleGrantedAuthority(role.getRoleName().name()))
.collect(Collectors.toSet());
return authorities;
}

@Override
public String getPassword() {
return user.getPassword();
}

@Override
public String getUsername() {
return user.getUsername();
}

@Override
public boolean isAccountNonExpired() {
return true;
}

@Override
public boolean isAccountNonLocked() {
return true;
}

@Override
public boolean isCredentialsNonExpired() {
return true;
}

@Override
public boolean isEnabled() {
return true;
}
}

UserDetailService 實作 (Custom UserDetailService)

接著需要加入管理的部分, 先前有提到原本預設管理元件 InMemoryUserDetailsManager 它其實就會去實作 UserDetailService,裡面有一個方法是 loadUserByUsername 需要實作,這邊需要實作用 username 來找出 User 資料,然後把 User 封裝成 UserDetails

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Service
public class MyUserDetailsService implements UserDetailsService {

@Autowired
private UserDao repo;

@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
User user = repo.findByUsername(username);
if (user == null) {
System.out.println("username = " + username + " not found");
throw new UsernameNotFoundException("user not found");
}
return new UserPrincipal(user);
}
}

UserController, UserService 實作確認可創建及撈出資料

建立 UserController 的幾個主要端口讓 user 可以先創建跟查詢,這邊我們透過 userRequest 的 DTO 來傳遞註冊時的帳號密碼等資訊

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
@RestController
@RequestMapping("/api")
public class UserController {

@Autowired
private UserService userService;

@GetMapping("/users")
public ResponseEntity<List<User>> getUsers() {
List<User> userList = userService.getUsers();
return ResponseEntity.status(HttpStatus.OK).body(userList);
}

@GetMapping("/users/{id}")
public ResponseEntity<Optional<User>> getUser(@PathVariable Integer id) {
Optional<User> user = userService.getUserByID(id);
if (user.isPresent()) {
return ResponseEntity.status(HttpStatus.OK).body(user);
}
return ResponseEntity.status(HttpStatus.NOT_FOUND).build();
}

@PostMapping("/register")
public ResponseEntity<User> Register(@RequestBody @Valid UserRequest userRequest) {
User createdUser = userService.createUser(userRequest);
return ResponseEntity.status(HttpStatus.CREATED).body(createdUser);
}
}

Service 這邊主要處理資料轉換,註冊的部分要 save user 預設先用一個 set 裝入預設的角色,我們是設定一般辦好會有買家跟賣家的權限,所以從 UserRole Enum 裡面取得放入。

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
@Service
public class UserService {
@Autowired
private UserDao userDao;

@Autowired
private RoleDao roleDao;

public List<User> getUsers() {
return userDao.findAll();
}

public Optional<User> getUserByID(Integer id) {
return userDao.findById(id);
}

@Transactional
public User createUser(UserRequest userRequest) {
Set<Role> roles = new HashSet<>();
List<String> roleList = Arrays.asList(UserRole.ROLE_BUYER.name(), UserRole.ROLE_SELLER.name());
User user = convertToModel(userRequest);
user.getRoles().addAll(roles);
return userDao.save(user);
}

private User convertToModel(UserRequest userRequest) {
User user = new User();
user.setUsername(userRequest.getUsername());
user.setEmail(userRequest.getEmail());
user.setPassword(userRequest.getPassword());
return user;
}
}

UserRequest DTO

DTO 內應用 @Valid 相關的驗證註解設定一些對應的參數規格

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
package com.oseanchen.crudproject.dto;

import jakarta.validation.constraints.*;
import lombok.Data;

@Data
public class UserRequest {
@NotBlank(message = "username can not be blank")

private String username;

@NotBlank(message = "password can not be blank")
@Size(min = 6, message = "password length at least 6 characters")
private String password;

@NotBlank(message = "email can not be blank")
@Email(message = "invalid email format")
private String email;
}

建立 PasswordEncoder 提升資安規格

使用 Bcrypt 加密方式,比起 MD5 或是 SHA 的雜湊演算更安全,因為加入隨機生成的鹽值 salt 可以防止駭客使用彩虹表來進行破解。

Service 引入 BCryptPasswordEncoder

引入後將準備儲存的密碼進行加密。

1
2
3
4
5
6
7
8
9
10
11
12
private BCryptPasswordEncoder encoder = new BCryptPasswordEncoder(12);

@Transactional
public User createUser(UserRequest userRequest) {
Set<Role> roles = new HashSet<>();
List<UserRole> roleList = Arrays.asList(UserRole.ROLE_BUYER, UserRole.ROLE_SELLER);
// 改加入 encoder
userRequest.setPassword(encoder.encode(userRequest.getPassword()));
User user = convertToModel(userRequest);
user.getRoles().addAll(roles);
return userDao.save(user);
}

最後可以測試目前儲存的資料,假設我們放入下面註冊的資料。

1
2
3
4
5
6
7
8
9
10
11
12
{
"username" : "sean",
"password" : "sean123",
"email":"kim@123.com"
}

{
"username" : "tom",
"password" : "tom123",
"email":"tom@123.com"
}

資料庫儲存時應該看到的資料像是下面這樣,密碼會是加密過的。
https://ithelp.ithome.com.tw/upload/images/20240912/20150977gPv8Bk0TLP.png

Config 改變 AuthenticationProvider Encoder 方式

這邊會透過 DaoAuthenticationProvider 重新設定 AuthenticationProvider 所使用的 userDetailsService, 還有 passwordEncoder

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@EnableWebSecurity
@Configuration
public class SecurityConfig {

@Autowired
private UserDetailsService userDetailsService;

@Bean
public AuthenticationProvider authProvider() {
DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
provider.setUserDetailsService(userDetailsService);
provider.setPasswordEncoder(new BCryptPasswordEncoder(12));
return provider;
}

@Bean
public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception {
//...略
}

實現登入

最後直接啟動程式連到 http://localhost:8080/login 確認可以正常登入,且登入之後可以根據我們 config 配置的權限進行瀏覽。

例如我上面 Config 配置只讓 ADMIN 的使用者可以查詢所有 user 的資料,如果是具有該權限的就可以成功瀏覽,但是其他使用者查詢就會是 403 Forbidden

先使用 sean 登入
https://ithelp.ithome.com.tw/upload/images/20240913/20150977Vqw5hWttRR.png

再使用 tom 登入
https://ithelp.ithome.com.tw/upload/images/20240913/20150977H4P40dESR1.png

總結:

以上就完成串接自己資料庫並且實現 Security 基本驗證的框架示範,總結一下這部分因為太多步驟要處理:

  1. 建立資料庫 schema, Entity、串連 Spring Data JPA 和資料庫
  2. UserDetails 介面實作
  3. UserDetailsService 介面實作撈出 user 並且封裝成 UserDetails 回傳
  4. 建置 Controller 及 Service 測試
  5. 加入 BCryptPasswordEncoder 實作,引入註冊帳號加密密碼後儲存、Config 重設 AuthenticationProvider 加入對應的 BCryptPasswordEncoder

希望大家對於 Spring Security 有更多的認識,也可以自己建立起保護 server 的環境喔! 下一篇會引入現今業界常使用到的 JWT 方式來進行登入及相關權限的管控。


參考資料: