跳到主要內容

初探 Spring Security

 前言


在當今瞬息萬變的 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 資料表







祝妳好運,謝謝!



留言

這個網誌中的熱門文章

初探 Vue 呼叫 API 出現 CORS 跨來源資源共享 問題原因

提要:   在 {初探Vue 與 Spring boot 的對話} 專案 ,前端 Vue 應用程式 串接 後端 API 伺服器 ,axios 呼叫 API 時出現以下,”無法取得回應內容 (No 'Access-Control-Allow-Origin' header is present on the requested resource):” 錯誤訊息,根據查找相關資料 ,出現以下原因。 瀏覽器開發工具 錯誤訊息 畫面 錯誤原因: “ Access to XMLHttpRequest at ” from origin ‘http://localhost:8080’ has been blocked by CORS policy: No ‘Access-Control-Allow-Origin’ header is present on the requested resource” 瀏覽器為了安全考量,實施了同源政策。 當您的前端應用程式 (http://localhost:8080) 嘗試呼叫一個不同來源 (不同協議、不同域名或不同埠號) 的 API 伺服器 (http://localhost:8088) 時,瀏覽器會主動阻止這個請求,除非伺服器明確地允許這個跨來源的存取。 同源政策限制(Same-Origin Policy): 同源政策限制了程式碼和不同網域資源間的互動,同源是指兩份網頁具備相同協定、埠號(如果有指定)以及主機位置 範例: 表列哪些 URL 與 URL http://www.example.com/api/p1 屬於同源: URL                                                   | 結果   | 原因 --------------------------------------------------------------------- http://www.example.com/api/p2     |...

初探 Vue 與 Spring boot 的對話之Frontend (Vue-Frontend)

  Front-end Vue 使用 REST API 建立 Vite 專案 可參考 { Vue 3 初探}  文章 danny@Danny-Yu projects % npm create vite@latest Need to install the following packages: create-vite@8.2.0 Ok to proceed? (y) y > npx > "create-vite" │ ◇   Project name: │   vue-frontend │ ◇   Select a framework: │   Vue │ ◇   Select a variant: │   TypeScript │ ◇   Use rolldown-vite (Experimental)?: │   No │ ◇   Install with npm and start now? │   Yes │ ◇   Scaffolding project in /Users/danny/Desktop/projects/vue-frontend... │ ◇   Installing dependencies with npm... added 47 packages, and audited 48 packages in 27s 6 packages are looking for funding   run `npm fund` for details found 0 vulnerabilities │ ◇   Starting dev server... > vue-frontend@0.0.0 dev > vite   VITE v7.2.4   ready in 411 ms   ➜   Local:   http://localhost:5173/   ➜   Network: use --host to expose   ➜   press h + enter to show...

初探 Spring 中的循環依賴

原因: 當兩個或多個 bean 直接或間接地相互依賴時, 就會出現 Circular Dependency (循環依賴) 如: Bean A -> Bean B -> Bean A import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; @Component public class BeanA {          @Autowired     private BeanB beanB;     public String sayHi() {         return "Hi! 我是 Class A.";     } } import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; @Component public class BeanB {          @Autowired     private BeanA beanA;     public String sayHi() {         return "Hi! 我是 Class B.";     } } 編譯時不會出現問題 danny@Danny-Yu demo % mvn clean install -Dmaven.test.skip=true                           ... ... [INFO] Installing /Users/danny/Desktop/projects/demo/target/dem...