前言
在當今瞬息萬變的 Web 環境中,應用程式安全比以往任何時候都更加重要。為保護服務、資料等各項資源,不被任意存取。Spring 提供了 Spring Security 驗證框架,它能幫助我們開發有關認證與授權等有關安全管理的功能。下面讓我們透過簡單的例子初窺如何運用。
專案實作
註: 基於 初探 Vue 與 Spring boot 的對話之Backend (SpringBoot-Backend) 文章 專案延生
1. 新增 相關 Dependencies
Pom.xml
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>
備註:
spring-security-test 官方提供的測試套件,用來在 單元測試 與整合測試 中方便地測試與 Spring Security 相關的功能
2.增修相關代碼
增修 Web 安全性, 網路安全配置類別 WebSecurityConfig
/*
Web 安全性配置, 網路安全配置
*/
@Configuration
@EnableWebSecurity
@EnableMethodSecurity(prePostEnabled = true)
public class WebSecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http.csrf(csrf -> csrf.disable())
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/public/**", "/login").permitAll()
.requestMatchers("/api/user/**").hasAnyRole("USER", "ADMIN")
.requestMatchers("/api/users/**").hasRole("ADMIN")
.requestMatchers(HttpMethod.POST, "/api/user").hasRole("ADMIN")
.requestMatchers(HttpMethod.DELETE, "/api/users/*")
.hasRole("ADMIN")
// 預設情況下,Spring Security 要求每個請求都必須經過身份驗證
.anyRequest().authenticated())
.httpBasic(Customizer.withDefaults())
.formLogin(Customizer.withDefaults())
.build();
}
/**
* 使用 InMemoryUserDetailsManager,建立帳號與密碼並儲存於記憶體中
* 用於測試,定義帶有不同權限的用戶。
*/
@Bean
public UserDetailsService userDetailsService(PasswordEncoder passwordEncoder) {
// ADMIN 用戶:擁有 ADMIN 角色
UserDetails admin = User
.withUsername("admin")
.password(passwordEncoder.encode("password"))
.roles("ADMIN")
.build();
// USER 用戶:擁有 USER 角色
UserDetails normalUser = User
.withUsername("user")
.password(passwordEncoder.encode("password"))
.roles("USER")
.build();
// GUEST 用戶:沒有任何角色
UserDetails guest = User
.withUsername("guest")
.password(passwordEncoder.encode("password"))
.roles("GUEST")
.build();
return new InMemoryUserDetailsManager(admin, normalUser, guest);
}
@Bean
public BCryptPasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
備註:
/api/users 僅匹配 完全相同 的路徑。
例子:
/api/users 匹配
/api/users/ 不匹配
/api/users/123 不匹配
/api/users/** 匹配以 /api/users/ 開頭的 所有路徑,無論子路徑有多少層級。
例子:
/api/users 匹配
/api/users/ 匹配
/api/users/123 匹配
/api/users/data/1 匹配
‘’’
permitAll | 不必登入認證就能存取。 |
hasAuthority | 需具備某一個權限才能存取。 |
hasAnyAuthority | 只要具備任一個權限就能存取。 |
authenticated | 需登入認證才能存取。 |
‘’’
增修 Entity
@Entity
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Table(name = "users")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "username", nullable = false)
private String username;
@Column(name = "password", nullable = false)
private String password;
@Column(name = "first_name", nullable = false)
private String firstName;
@Column(name = "last_name")
private String lastName;
@Column(name = "email", nullable = false, unique = true)
private String email;
public User(String username, String password, String firstName, String lastName, String email) {
this.username = username;
this.password = password;
this.firstName = firstName;
this.lastName = lastName;
this.email = email;
}
}
增修 UserRepository
@Repository
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByUsername(String username);
Boolean existsByUsername(String username);
Boolean existsByEmail(String email);
}
增修 UserSerice
@Slf4j
@Service
public class UserService {
@Autowired
private UserRepository userRepository;
@Transactional
@PreAuthorize("hasAnyRole('ADMIN', 'USER')")
public User saveUser(User user) {
log.info("Saving user: " + user.getUsername());
if (user == null) {
throw new IllegalArgumentException("User must not be null");
}
return userRepository.save(user);
}
/*
* @PreAuthorize: 在方法執行之前,決定是否允許訪問
*/
@PreAuthorize("hasAuthority('ADMIN')")
public List<User> getUsers() {
List<User> users = null;
try {
users = userRepository.findAll();
log.debug("Number of users fetched: " + users.size());
} catch (Exception e) {
e.printStackTrace();
}
return users;
}
public User getUserById(Long uid) {
if (uid == null) {
throw new UserNotFoundException(null);
}
User user = userRepository.findById(uid)
.orElseThrow(() -> new UserNotFoundException(uid));
return user;
}
public User updateUser(@RequestBody User newUser, @PathVariable Long id) {
log.info("Updating user with id: " + id);
return userRepository.findById(id)
.map(user -> {
user.setUsername(null == newUser.getUsername() ? user.getUsername() : newUser.getUsername());
user.setPassword(null == newUser.getPassword() ? user.getPassword() : newUser.getPassword());
user.setFirstName(null == newUser.getFirstName() ? user.getFirstName() : newUser.getFirstName());
user.setLastName(null == newUser.getLastName() ? user.getLastName() : newUser.getLastName());
user.setEmail(null == newUser.getEmail() ? user.getEmail() : newUser.getEmail());
return userRepository.save(user);
})
.orElseGet(() -> {
return userRepository.save(newUser);
});
}
public void deleteUser(Long uid) {
if (uid == null) {
throw new UserNotFoundException(null);
}
userRepository.deleteById(uid);
}
}
增修 Controller
@Slf4j
@RestController
@RequestMapping("/api")
public class UserController {
@Autowired
private UserService userService;
@Autowired
private UserRepository userRepository;
@GetMapping("/public")
public String publicApi() {
return "public OK";
}
@PostMapping("/user")
public ResponseEntity<?> createUser(@RequestBody User newUser) {
User user = userService.saveUser(newUser);
return ResponseEntity.ok(user);
}
@GetMapping("/user/{uid}")
public User getUserById(@PathVariable Long uid) {
return userService.getUserById(uid);
}
@GetMapping("/users")
public List<User> getAllUsers() {
List<User> users = userRepository.findAll();
return users;
}
@PutMapping("/users/{uid}")
User replaceUser(@RequestBody User newUser, @PathVariable Long uid) {
return userService.updateUser(newUser, uid);
}
@DeleteMapping("/users/{uid}")
@PreAuthorize("hasAuthority('delete')")
void deleteUser(@PathVariable Long uid) {
userService.deleteUser(uid);
}
}
建立 SpringBootTest 和 MockMvc 測試範例
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.MethodOrderer;
import org.junit.jupiter.api.Order;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestMethodOrder;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.context.WebApplicationContext;
import com.dannyyu.backend.model.User;
import com.fasterxml.jackson.databind.ObjectMapper;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.context.SpringBootTest.WebEnvironment;
import org.springframework.http.MediaType;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user;
import static org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.*;
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
@AutoConfigureMockMvc
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
public class SecurityTest {
@Autowired
private WebApplicationContext context;
private MockMvc mockMvc;
static Long uid = 0L;
@BeforeEach
public void setup() throws Exception {
mockMvc = MockMvcBuilders
.webAppContextSetup(context)
.apply(springSecurity())
.build();
}
@Test
@Order(7)
@WithMockUser(username = "admin", authorities = { "delete", "ROLE_ADMIN" })
void testDeleteUser() throws Exception {
mockMvc.perform(delete("/api/users/" + uid))
.andExpect(status().isOk());
}
@Test
@Order(1)
@WithMockUser(username = "admin", roles = { "ADMIN" })
public void testCreateUser() throws Exception {
MvcResult result = null;
User createdUser = null;
String json = "";
User user = new User("test", "123456", "test", "wu", "test@example.com");
String jsoString = asJsonString(user);
result = mockMvc.perform(
MockMvcRequestBuilders
.post("/api/user")
.content(
jsoString)
.contentType(MediaType.APPLICATION_JSON)
.accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andReturn();
json = result.getResponse().getContentAsString();
createdUser = new ObjectMapper().readValue(json, User.class);
uid = createdUser.getId();
}
// Public API 無需登入
@Test
@Order(2)
@WithMockUser(roles = { "USER" })
void testPublicApi() throws Exception {
mockMvc.perform(get("/api/public"))
.andExpect(status().isOk());
}
// 測試 USER角色可存取 /user
@Test
@Order(3)
@WithMockUser(username = "user", roles = { "USER" })
void testUserApi() throws Exception {
mockMvc.perform(get("/api/user/" + uid))
.andExpect(status().isOk());
}
// 測試 USER 不能存取 /admin
@Test
@Order(4)
@WithMockUser(roles = { "USER" })
void testAdminDenied() throws Exception {
mockMvc.perform(get("/api/users"))
.andExpect(status().isForbidden());
}
// ADMIN 可存取 /users
@Test
@Order(5)
@WithMockUser(roles = { "ADMIN" })
void testAdminApi() throws Exception {
mockMvc.perform(get("/api/users"))
.andExpect(status().isOk());
}
@Test
@Order(6)
void testUserApiWithRequestPostProcessor() throws Exception {
String responseBody = mockMvc.perform(
get("/api/user/" + uid).with(
user("admin").roles("ADMIN")))
.andExpect(status().isOk())
.andReturn().getResponse().getContentAsString();
System.out.println("****** 取得角色: ******");
System.out.println(responseBody);
}
public static String asJsonString(final Object obj) {
try {
return new ObjectMapper().writeValueAsString(obj);
} catch (Exception e) {
throw new RuntimeException(e);
}
}
}
測試:
% mvn test
. . .
[INFO] -------------------------------------------------------
[INFO] T E S T S
[INFO] -------------------------------------------------------
[INFO] Running com.dannyyu.backend.controller.SecurityTest
. . .
. . .
Hibernate: select u1_0.id,u1_0.email,u1_0.first_name,u1_0.last_name,u1_0.password,u1_0.username from users u1_0 where u1_0.id=?
Hibernate: delete from users where id=?
[INFO] Tests run: 7, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 2.966 s -- in com.dannyyu.backend.controller.SecurityTest
[INFO]
[INFO] Results:
[INFO]
[INFO] Tests run: 7, Failures: 0, Errors: 0, Skipped: 0
[INFO]
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
. . .
使用 Browser 測試
測試 USER 不能存取 /users (只有admin才可以查看所有使用者)
使用 user 登入
沒處理登入後頁面, 出現 Error Page, 不用 care
修改 URL http://localhost:8088/api/users , Enter (查看所有使用者)
權限不足導致請求失敗
有關HTTP 回應狀態碼可參看以下網址
https://developer.mozilla.org/zh-TW/docs/Web/HTTP/Reference/Status
打 http://localhost:8088/logout 登出
改使用 Admin 登入
修改 URL http://localhost:8088/api/users , Enter (查看所有使用者)
資料來源 users 資料表
祝妳好運,謝謝!
留言