iT邦幫忙

2023 iThome 鐵人賽

DAY 22
0
Software Development

救救我啊我救我!CRUD 工程師的惡補日記系列 第 22

【Spring Security】引進到 Spring Boot 並保護 API

  • 分享至 

  • xImage
  •  

Spring Security 是一個框架,它能幫助我們開發有關認證(authentication)與授權(authorization)等有關安全管理的功能。Day 22 ~ 26 為一個小系列,而本文將認識 Spring Security 的初始設置。

首先會在範例專案中準備使用者的資料 model、REST API,以及簡易業務邏輯。接著再設置授權規則,決定開放 API 的方式。透過適當地設置規則,可以達到保護 API,不被使用者任意存取的目的。

此篇在 2024 年於「【Spring Boot】第17.1課-初探 Spring Security 的認證與授權」文章更新。


一、準備 Spring Boot 專案

為了實作人的權限管控,系統中理所當然要有「使用者」這項元素。本節主要是準備好使用者相關的程式碼,在後面這幾篇文章,都會以此為基礎。筆者不一定會仔細說明前面文章已實作過的東西。

(一)使用者 Model

以下簡單地設計了名為 AppUser 的類別,用來描述使用者。

public class AppUser {
    private String id;
    private String email;
    private String password;
    private UserAuthority authority;

    public static AppUser getTestAdminUser() {
        var user = new AppUser();
        user.id = "100";
        user.email = "vincent@gmail.com";
        user.password = "123456";
        user.authority = UserAuthority.ADMIN;

        return user;
    }

    public static AppUser getTestNormalUser() {
        var user = new AppUser();
        user.id = "101";
        user.email = "dora@gmail.com";
        user.password = "654321";
        user.authority = UserAuthority.NORMAL;

        return user;
    }

    public static AppUser getTestGuestUser() {
        var user = new AppUser();
        user.id = "000";
        user.email = "ivy@gmail.com";
        user.password = "000000";
        user.authority = UserAuthority.GUEST;

        return user;
    }
    
    // getter, setter ...
}

其中 UserAuthority 這個列舉類別,代表使用者的身份。在此分為「管理員」、「一般」與「訪客」。

public enum UserAuthority {
    ADMIN, NORMAL, GUEST;
}

AppUser 類別提供三個範例資料。
||帳號||密碼||身份||
|-|-|-|
|vincent@gmail.com|123456|管理員|
|dora@gmail.com|654321|一般|
|ivy@gmail.com|000000|訪客|

(二)REST API 與業務邏輯

以下的 controller 提供了三個 API,用途分別如下。

  • POST /users:建立使用者
  • GET /users:列出所有使用者
  • GET /users/{id}:取得一筆使用者資料
@RestController
public class DemoController {

    @Autowired
    private UserRepository userRepository;

    @PostMapping("/users")
    public ResponseEntity<Void> createUser(@RequestBody AppUser user) {
        userRepository.insert(user);
        return ResponseEntity.ok().build();
    }
    
    @GetMapping("/users")
    public ResponseEntity<List<AppUser>> getAllUsers() {
        var users = userRepository.findAll();
        return ResponseEntity.ok(users);
    }

    @GetMapping("/users/{id}")
    public ResponseEntity<AppUser> getUser(@PathVariable String id) {
        var user = userRepository.findById(id);
        return user != null
                ? ResponseEntity.ok(user)
                : ResponseEntity.notFound().build();
    }
}

由於本文的範例專案沒有串接真實的資料庫,故以 HashMap 代替,並用自定義的 UserRepository 類別封裝起來。此外還預先添加數筆使用者資料。

@Repository
public class UserRepository {
    private final Map<String, AppUser> idToUserMap = new HashMap<>();

    @PostConstruct
    private void init() {
        var adminUser = AppUser.getTestAdminUser();
        var normalUser = AppUser.getTestNormalUser();
        var guestUser = AppUser.getTestGuestUser();
        
        idToUserMap.put(adminUser.getId(), adminUser);
        idToUserMap.put(normalUser.getId(), normalUser);
        idToUserMap.put(guestUser.getId(), guestUser);
    }

    public AppUser findById(String id) {
        return idToUserMap.get(id);
    }

    public AppUser findByEmail(String email) {
        return idToUserMap.values().stream()
                .filter(u -> u.getEmail().equals(email))
                .findFirst()
                .orElse(null);
    }

    public List<AppUser> findAll() {
        return new ArrayList<>(idToUserMap.values());
    }

    public void insert(AppUser user) {
        idToUserMap.put(user.getId(), user);
    }
}

讀者可自行先試用看看這些 API。

(三)添加依賴

為了使用 Spring Security 的 library,請在 pom.xml 檔案添加依賴。

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>

添加依賴後,讀者會發現所有的 API 突然不能用了,使用時都得到 HTTP 401 的狀態碼(Unauthorized)。這是因為 Spring Security 預設會將所有的 API 保護起來。

二、配置 API 授權規則

(一)建立配置類別

添加 library 後,請建立一個名為 SecurityConfig 的 config 類別,並冠上 @EnableWebSecurity 標記。我們將在此配置 API 的授權規則。

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        // TODO
        return http.build();
    }
}

Spring Boot 3 版本的配置程式寫法,與以前版本稍微不同。上面建立了 SecurityFilterChain 的元件,它會被 Spring Security 讀取,套用裡面的設定。而接下來就是要做「設定」這件事。

(二)設計授權規則

在建立 SecurityFilterChain 的過程中,透過呼叫 HttpSecurity 的方法,可幫助我們配置 API 的授權規則。

筆者將規則設計為:

  • 所有人都能建立使用者。
  • 所有人都能查看單一使用者的資料。
  • 其餘 API 需通過身份認證才能存取。

以下的配置程式,會在 HttpSecurity.authorizeHttpRequests 方法中進行 functional programming。

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
                .authorizeHttpRequests(registry ->
                        registry
                                .requestMatchers(HttpMethod.POST, "/users").permitAll()
                                .requestMatchers(HttpMethod.GET, "/users/?*").permitAll()
                                .anyRequest().authenticated()
                )
                .csrf(AbstractHttpConfigurer::disable);
        return http.build();
    }
}

以上的範例程式,有幾點需要說明。

設置一組授權規則時,會使用 requestMatcher 方法,傳入 HTTP 方法與 API 路徑。

接著搭配 permitAll 方法,可將授權方式設置為「允許所有人」。若搭配 authenticated 方法,則設置為「需通過身份認證」。本文只使用這兩種,下一篇文章會介紹第三種。

另外這邊還停用了有關 CSRF 的保護機制。若維持預設啟用的狀態,當存取 API 時,除了 HTTP 的 GET 方法,其他如 POST 都會被阻擋。

關於 /users/?*?* 符號,會在下面說明。

(三)匹配多個 API 路徑

在上面的範例程式中,我們想設置 GET /users/{id} 這個 API 的授權規則,因而在 requestMatcher 方法的參數寫了 ?*。其實這是一種「萬用字元」,該方法除了傳入精確的路徑,也能傳入格式(pattern)來做模糊匹配。

以下是萬用字元的用法與範例。
||符號||意義||範例格式||適用||不適用||
|-|-|-|-|-|
|*|0 到多個字元|/posts/*|/posts/posts/123|/posts/123/draft|
|**|0 到多個階層|/posts/**|任何 /posts 開頭的路徑|-|
|?|1 個字元|/posts/?|/posts/1|/posts/123/posts|

若 pattern 為 /posts/?*,則 /posts/123 就有符合了。

三、密碼加密

第一節的 AppUser 範例使用者,其密碼是以明文(plain text)來儲存的。然而這並不是適當的做法,應該以密文(cipher text)來儲存,避免開發人員盜用的疑慮。

為此,讓我們先將 Spring Security 內建的密碼加密工具 BCryptPasswordEncoder,建立成元件。

@Configuration
@EnableWebSecurity
public class SecurityConfig {
    // ...

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
}

接著將其注入到 UserRepository,於儲存使用者的程式碼中,呼叫 encode 方法來加密。

@Repository
public class UserRepository {
    private final Map<String, AppUser> idToUserMap = new HashMap<>();

    @Autowired
    private PasswordEncoder passwordEncoder;

    @PostConstruct
    private void init() {
        var adminUser = AppUser.getTestAdminUser();
        var normalUser = AppUser.getTestNormalUser();
        var guestUser = AppUser.getTestGuestUser();

        Stream.of(adminUser, normalUser, guestUser)
                .forEach(u -> {
                    var encodedPwd = passwordEncoder.encode(u.getPassword());
                    u.setPassword(encodedPwd);
                });

        idToUserMap.put(adminUser.getId(), adminUser);
        idToUserMap.put(normalUser.getId(), normalUser);
        idToUserMap.put(guestUser.getId(), guestUser);
    }
    
    public void insert(AppUser user) {
        var encodedPwd = passwordEncoder.encode(user.getPassword());
        user.setPassword(encodedPwd);
        idToUserMap.put(user.getId(), user);
    }

    // ...
}

四、測試

前面已經配置好這三支 API 的授權規則了,讓我們試著用 Postman 存取看看。

首先是 GET /users/{id},匹配到的規則為 /users/?*。授權方式為 permitAll,所以可以正常存取。
https://ithelp.ithome.com.tw/upload/images/20230929/20131107YnAxvaLa7R.jpg

接著是 POST /users,授權方式為 permitAll,同樣也可以正常存取。
https://ithelp.ithome.com.tw/upload/images/20230929/20131107yai7Zcynjh.jpg

最後是 GET /users,由於需要通過身份認證,目前無法存取。得到 HTTP 403(Forbidden)的狀態碼。
https://ithelp.ithome.com.tw/upload/images/20230929/20131107PMekjn1XgE.jpg

那麼要如何處理身份認證的問題呢?筆者會在明天的文章進行實作。

本文的完成專案:
https://github.com/ntub46010/SpringBootTutorial/tree/Ch17-1

Ref:Securing a Web Application(官方文件)


今日文章到此結束!
最後推廣一下自己的部落格,我是「新手工程師的程式教室」的作者,請多指教/images/emoticon/emoticon41.gif


上一篇
【RabbitMQ】在 Spring Boot 實作 Routing 與 Topic 模式
下一篇
【Spring Security】實作身份認證與 API 存取授權
系列文
救救我啊我救我!CRUD 工程師的惡補日記50
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言