上一篇引進了 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 個參數。分別叫做 username
、password
與 authorities
。其中 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
介面的內容,讀者可以知道一下。
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 的授權規則。當時使用 permitAll
與 authenticated
方法,分別設為「允許任何人」與「需通過認證」。而這一節將利用本文第二節第二段所實作的「權限名稱」,重新設計一次授權規則,讓讀者體會效果。
筆者在以下的配置程式,將規則設計為:
@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
」,能看到如下的登入畫面。
接著以管理員範例帳號登入(帳號:vincent@gmail.com,密碼:123456),便可看到使用者列表的 JSON 資料。
若以一般使用者的範例帳號登入(帳號:dora@gmail.com,密碼:654321),將得到 HTTP 403(Forbidden)的狀態碼。
會出現「Whitelabel Error Page」畫面,是因為程式專案中沒有配置這種情況要顯示什麼畫面。於是 Spring Boot 便顯示預設的。
本文第二節第三段的 AuthenticationProvider
會被登入畫面自動使用,但這種形式只是為了測試用途。在前後端分離的系統,理應由後端提供登入相關的 REST API 供前端呼叫。下一篇便是要做這件事。
本文的完成專案:
https://github.com/ntub46010/SpringBootTutorial/tree/Ch17-2
今日文章到此結束!
最後推廣一下自己的部落格,我是「新手工程師的程式教室」的作者,請多指教