iT邦幫忙

2023 iThome 鐵人賽

DAY 23
0
Software Development

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

【Spring Security】實作身份認證與 API 存取授權

  • 分享至 

  • xImage
  •  

上一篇引進了 Spring Security,並將 REST API 的授權規則設計為「任何人皆能存取」與「需通過身份認證」。而本文的目標,就是實作身份認證的邏輯,並為那些已通過認證的人設計權限。接著會再認識一個新的 API 授權規則,也就是只讓具有特定權限的人存取,藉此加強對已通過認證的人的管控。

此篇在 2024 年於「【Spring Boot】第17.2課-在 Spring Security 整合資料庫進行認證」文章更新。


一、認證與授權的定義

首先說明認證(authentication)與授權(authorization)的意思。認證是確認身份,授權則是允許對方的操作。

以生活上的例子來比喻,可以想像一所高中有圖書館和餐廳這兩個場所。而高中不是任何人都能自由進出的地方,若想進入校門,就得通過認證,例如證明自己是該校的學生、教職員或餐廳員工等。對應到資訊系統,相當於透過帳密登入。

若對應到 Day 22 在程式中配置的 API 授權規則,那就是使用 requestMatchers(...).authenticated() 這個方法。

校內的資源也不是進入學校的任何人都能使用。比方說餐廳開放大家都能進入消費,但圖書館僅限學生與教職員身份才可使用,餐廳員工沒有權利。但若身兼學生與餐廳員工身份,則仍可使用。對應到資訊系統,也就是規定什麼功能可以給哪些身份使用。

二、實作認證程式

回顧一下昨天配置 API 授權規則的部份。當時寫下了 requestMatchers(HttpMethod.GET, "/users").authenticated() 的方法呼叫,代表將 GET /users 這支 API,設置為需要通過身份認證才可存取。本節的目標就是要實作認證的程式。

(一)認證程式

請建立一個 service 元件類別,並實作 UserDetailsService 介面。

import org.springframework.security.core.userdetails.User;

@Service
public class UserDetailsServiceImpl implements UserDetailsService {

    @Autowired
    private UserRepository userRepository;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        AppUser appUser = userRepository.findByEmail(username);
        if (appUser == null) {
            throw new UsernameNotFoundException("Can't find user: " + username);
        }

        return new User(appUser.getEmail(), appUser.getPassword(), List.of());
    }
}

實作此介面需覆寫 loadUserByUsername 方法,其目的如同名稱,是要透過帳號來找出使用者。而方法回傳值為 UserDetails 介面,它是在 Spring Security 中,用來傳遞使用者資訊的介面,並且有一個內建的實作類別 User

上面的範例程式,會從 UserRepository 取出 AppUser,再包裝成 User 物件。

User 的建構子接收了 3 個參數。分別叫做 usernamepasswordauthorities。其中 authorities 稍後才會說明,故此處暫時給予空 List。

(二)設計權限

即便通過了身份認證,然而並不能放任這個人存取所有的 API。假想學校有個校務系統,那麼學生身份並不能做如開設課程的的動作,而老師也不能執行選課。

再以本文的程式專案為例,假設某人的身份是「一般使用者」,但對於 GET /users 這支 API,系統總不能把 DB 中的所有使用者資料都給他看吧?照理說只有管理員身份才行。因此後端才透過 Spring Security 完成阻擋的措施。

剛剛在建立 User 物件時,暫時忽略了建構子的 authorities 參數,而接下來就要給予值了。該參數的目的,在於告訴 Spring Security 這個人擁有的權限是什麼名稱。例如此人是管理員身份,就說他的權限名稱是「ADMIN」,訪客身份則說是「GUEST」。

這個 authorities 參數的型態是 List<? extends GrantedAuthority>,即 GrantedAuthority 物件的 List。GrantedAuthority 是一個介面,為了方便,讓我們直接在昨天的 UserAuthority 列舉類別實作它。

public enum UserAuthority implements GrantedAuthority {
    ADMIN, NORMAL, GUEST;

    @Override
    public String getAuthority() {
        return name();
    }
}

覆寫的 getAuthority 方法,需回傳的是要給 Spring Security 看的權限名稱。此處簡單地回傳各個列舉物件的名字。

或者也可以使用內建的 SimpleGrantedAuthority,從它的建構子直接給予權限名稱。使用方式示意如下。

new SimpleGrantedAuthority(UserAuthority.ADMIN.name());

最後請回到 UserDetailsServiceImpl,添加到 User 物件中。

new User(appUser.getEmail(), appUser.getPassword(), List.of(appUser.getAuthority()));

此處所實作的權限名稱,會在設計 API 授權規則時運用到。

(三)套用到配置類別

請將完成的 UserDetailsService 與上一篇的 BCryptPasswordEncoder,注入到配置類別,用以建立 AuthenticationProvider 元件。另外為了測試用途,請啟用內建的登入畫面。

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public AuthenticationProvider authenticationProvider(
            UserDetailsService userDetailsService,
            BCryptPasswordEncoder passwordEncoder
    ) {
        var provider = new DaoAuthenticationProvider();
        provider.setUserDetailsService(userDetailsService);
        provider.setPasswordEncoder(passwordEncoder);
        return provider;
    }

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
                .authorizeHttpRequests(registry ->
                    // ...
                )
                .csrf(AbstractHttpConfigurer::disable)
                .formLogin(); // 開啟內建登入畫面 (測試用途)
        return http.build();
    }
    
}

AuthenticationProvider 是用來做身份認證的元件,它是一個介面。在此給予 DaoAuthenticationProvider,代表要依據 DB 中的資料來做認證。

在本文進行測試時,我們會有個登入畫面能輸入帳密。屆時按下登入鍵後,AuthenticationProvider 會從 UserDetailsService 取得使用者資訊。而 BCryptPasswordEncoder 會將我們在畫面上輸入的密碼做加密,與 User 物件中的密碼密文(來自 DB)做比對。若相符,則認證通過。

在昨天的範例程式,儲存使用者時會對密碼做加密。因此登入時當然也要將人輸入的明文密碼做加密,這樣才能比對。

(四)認識 UserDetails 介面

以下是 UserDetails 介面的內容,讀者可以知道一下。

public interface UserDetails extends Serializable {
    String getUsername();
    String getPassword();
    Collection<? extends GrantedAuthority> getAuthorities();

    boolean isAccountNonExpired();
    boolean isAccountNonLocked();
    boolean isCredentialsNonExpired();
    boolean isEnabled();
}

除了回傳帳號、密碼與權限,該介面也提供帳號是否過期、鎖定、啟用,還有密碼是否過期的方法。若想實作細部的身份認證,這四個方法能用於確認使用者的狀態。

對於前面的 User 物件,這四種狀態預設均為 true,讀者亦可在另一個 overloading 的建構子給予值。或者等到 Day 25,我們會自己準備一個實作 UserDetails 介面的類別,封裝判斷邏輯。

三、根據權限來授權 API 存取

在上一篇的範例程式中,有設置了 API 的授權規則。當時使用 permitAllauthenticated 方法,分別設為「允許任何人」與「需通過認證」。而這一節將利用本文第二節第二段所實作的「權限名稱」,重新設計一次授權規則,讓讀者體會效果。

筆者在以下的配置程式,將規則設計為:

  • 所有人都能建立使用者。
  • 需擁有管理員或一般使用者身份,才能查看單一使用者的資料。
  • 需擁有管理員身份,才能查看所有使用者的列表。
  • 其餘 API 需通過身份認證才能存取。
@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/?*").hasAnyAuthority("ADMIN", "NORMAL")
                                .requestMatchers(HttpMethod.GET, "/users").hasAuthority("ADMIN")
                                .anyRequest().authenticated()
                )
                // ...
    }

    // ...
}

上面使用了 hasAuthority 方法,將 API 授權給特定的一個權限,如 ADMIN。使用 hasAnyAuthority 方法,則可傳入陣列,只要 API 的呼叫方擁有其中一個權限,便能得到授權。

除了 hasAuthority,讀者應該還會看到 hasRole 方法。它們的用途相同,只是後者的權限名稱需考慮「ROLE_」的前綴。有興趣可參考「Spring Security 中的 hasRole 和 hasAuthority 有區別嗎?

四、測試

授權規則重新設計好了,也啟用 Spring Security 預設的登入畫面,最後讀者可測試一下效果。

在瀏覽器中前往 「http://localhost:8080/users」,能看到如下的登入畫面。
https://ithelp.ithome.com.tw/upload/images/20231002/20131107Z610HFaqd0.png

接著以管理員範例帳號登入(帳號:vincent@gmail.com,密碼:123456),便可看到使用者列表的 JSON 資料。
https://ithelp.ithome.com.tw/upload/images/20231029/20131107ZSvyrEjs6o.jpg

若以一般使用者的範例帳號登入(帳號:dora@gmail.com,密碼:654321),將得到 HTTP 403(Forbidden)的狀態碼。
https://ithelp.ithome.com.tw/upload/images/20231002/201311077F7w0lGHMW.jpg

會出現「Whitelabel Error Page」畫面,是因為程式專案中沒有配置這種情況要顯示什麼畫面。於是 Spring Boot 便顯示預設的。

本文第二節第三段的 AuthenticationProvider 會被登入畫面自動使用,但這種形式只是為了測試用途。在前後端分離的系統,理應由後端提供登入相關的 REST API 供前端呼叫。下一篇便是要做這件事。

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


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


上一篇
【Spring Security】引進到 Spring Boot 並保護 API
下一篇
【Spring Security】核發 JWT 並結合帳密認證(上)
系列文
救救我啊我救我!CRUD 工程師的惡補日記50
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言