To-Do Calendar - Day26 Spring Security 的認證與授權(一)
在前後端分離的架構中,前端和後端是通過 API 來傳遞資料,當使用者進行一些受到存取控制機制保護的操作,一般都會需要進行認證與授權,來保護 Web 的資源。本篇紀錄了如何在專案中使用 Spring Security 來實作安全認證與授權。
什麼是 Spring Security?
Spring Security 是 Spring 的一個安全管理框架,它基於 Spring AOP 和 Servlet 過濾器,為系統提供了身份驗證與訪問控制的功能,減少了為系統安全而編寫大量重複代碼的工作。
核心概念
- Principle:代表使用者的物件(User),使用者並不僅指人類,還包含一切可以用於驗證的裝置。
- Authority:代表使用者的角色(Role),每個使用者都應該有一種角色,像是管理員會是一般會員,而不同的角色所擁有的許可權是不同的。
- Permission:代表授權,對角色的許可權進行表述。
認證與授權
一般來說,Web 應用的安全性包括認證(Authentication)和授權(Authorization)兩個部分。
認證
- 指的是建立系統使用者資訊(Principle)的過程。
- 支援主流的驗證方式,包含 HTTP 基本認證、HTTP 表單驗證、HTTP 摘要認證、OpenID 和 LDAP 等。
- 一般要求使用者提供使用者名稱和密碼,系統透過驗證使用者名稱和密碼的正確性來完成認證的過程。
授權
- 在一個系統中,不同角色具有的許可權是不同的。
- 它判斷某個 Principle 在應用程式中是否允許執行某個操作。
- 在進行授權判斷前,要求其所要使用到的規則必須在驗證過程中就建立好了。
Spring Security 的運作流程
- Spring Security 的原理其實就是一個過濾器鏈,內部包含了提供各種功能的過濾器。

- UsernamePasswordAuthenticationFilter:負責處理登入請求,預設是對
/login
的 POST 請求進行認證。 - ExceptionTranslationFilter:處理過濾器鏈中拋出的任何 AccessDeniedException 和 AuthenticationException。
- FilterSecurityInterceptor:負責授權(權限校驗)的過濾器。
預設的認證流程圖
- 先了解 Spring Security 的認證流程,才知道如何修改它,去實現我們自己的登入流程。
Spring Security 進行認證的步驟
- 使用者使用用戶名和密碼登入。
- UsernamePasswordAuthenticationFilter 取得用戶名、密碼,然後封裝成 Authentication。
- AuthenticationManager 認證 token(Authentication 的實現類別傳遞)。
- AuthenticationManager 認證成功,傳回一個封裝了使用者許可權資訊的 Authentication 物件。
- Authentication 物件設定值給目前的 SecurityContext,建立這個使用者的安全上下文(透過 SecurityContextHolder.getContext().setAuthentication())。
- 使用者進行一些受到存取控制機制保護的操作,存取控制機制會依據目前安全上下文資訊檢查這個操作所需的許可權。
從以上可以分析出,我們可以自定義一個 UserDetailsService 來替換預設的 InMemoryUserDetailsManager(在記憶體中查找使用者資料),讓 Spring Security 使用我們的 UserDetailsService(在資料庫中查找使用者資料)。
前後端分離的登入認證流程
- 前後端分離的情況下要進行認證,核心其實是依賴於 token。
思路分析
登入
- 自定義登入API
- 調用 ProviderManager 的方法進行認證,如果認證通過生成 jwt
- 把用戶信息存入 Redis 中
- 自定義 UserDetailsService
- 在這個實現類中去查詢數據庫
- 在這個實現類中去查詢數據庫
校驗
- 定義 token 校驗過濾器
- 取得 token
- 解析 token,取得其中的 userId
- 從 Redis 中取得使用者資料(優點:減少資料庫的訪問壓力、速度比查詢資料庫快)
- 存入 SecurityContextHolder
引入依賴
-
在 pom.xml 添加 Spring Security 配置
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency>
-
引入依賴後重新運行,嘗試去訪問請求會自動跳轉到 Spring Security 的預設登入頁面
-
使用 API Tester 發送請求則會收到 HTTP 401(Unauthorized)的狀態碼
-
預設的使用者名稱是 user,密碼會隨機生成輸出在控制台
-
登入後才能訪問請求
這是因為 Spring Security 預設所有 API 都要先通過身份驗證才可存取,後面會在 SecurityConfig 自訂 API 的授權規則。
建立自定義的 UserDetailsService 實現類
-
建立 UserDetailsServiceImpl class,並 implements UserDetailsService 介面
-
實作 loadUserByUsername 方法
- 這個方法從圖上可以得知是由 DaoAuthenticationProvider 所調用,它會把登入時的所填寫的用戶名傳到 loadUserByUsername 方法去進行查詢使用者資料
- 最後把查到的使用者資料封裝成 UserDetails 物件返回
@Component public class UserDetailsServiceImpl implements UserDetailsService { @Autowired private UserDao userDao; @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { // 查詢使用者資料 User user = userDao.getUserByEmail(username); // 如果使用者不存在就拋出異常 if (Objects.isNull(user)) { throw new AccountNotFoundException("帳號或密碼錯誤"); } // TODO:查詢對應的權限訊息 // 把資料封裝成 UserDetails 返回 return new LoginUser(user); } }
-
建立 UserDetails 實現類
- 建立 LoginUser class,並 implements UserDetails 介面
- 定義 User 變數
- 實作所有方法(後面4個返回是 boolean 的方法預設改返回 true)
@Data @NoArgsConstructor @AllArgsConstructor public class LoginUser implements UserDetails { private User user; @Override public Collection<? extends GrantedAuthority> getAuthorities() { return null; } @Override public String getPassword() { return user.getPassword(); } @Override public String getUsername() { return user.getEmail(); } @Override public boolean isAccountNonExpired() { return true; } @Override public boolean isAccountNonLocked() { return true; } @Override public boolean isCredentialsNonExpired() { return true; } @Override public boolean isEnabled() { return true; } }
-
替換完 UserDetailsService 後,運行 spring boot 再次測試訪問請求
-
按下 Sign in 後,發現登入失敗,而控制台會出現以下錯誤訊息
這是因為 Spring Security 預設的 PasswordEncoder,它要求實際查到的密碼格式為{id}password,它會根據 id 去判斷密碼的加密方式,如果是明文存儲,就是在密碼之前加{noop},但在實際專案中我們不會採用這種方式,所以接下來會將目前的 PasswordEncoder 給替換掉,改用 Spring Security 提供的 BCryptPasswordEncoder。
BCryptPasswordEncoder
- BCryptPasswordEncoder 實作了 bcrypt 加密演算法,是 Spring 官方推薦的加密方式,它會使用一個加鹽的流程以防禦彩虹表攻擊,就算是相同的密碼,因為每次產生的鹽值不同,編碼後的結果也就不會相同(鹽值會包含在編碼後的結果之中,不過 bcrypt 屬於 Slow Hash Function 手法,也就是破解它的時間成本高,高到可以讓攻擊者放棄)。
- Spring Security 中的 BCryptPasswordEncoder 方法採用 SHA-256 + 隨機鹽 + 密鑰對密碼進行加密。SHA 系列是 Hash 算法,其過程是不可逆的。
設定 Spring Security
- 建立 Spring Security 的配置類,並繼承 WebSecurityConfigurerAdapter
- 建立 BCryptPasswordEncoder 物件並使用
@Bean
註解將它注入到 Spring 容器中,Spring Security 就會使用該 PasswordEncoder 來進行密碼校驗@Configuration public class SecurityConfig extends WebSecurityConfigurerAdapter { @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } }
- 將資料庫中測試帳號的密碼改為用 BCryptPasswordEncoder 加密後的密文
- 此時重新運行後,就能登入成功訪問請求
處理註冊
- 注入 PasswordEncoder
- 對密碼進行加密
@Component public class UserDaoImpl implements UserDao { @Autowired private MongoTemplate mongoTemplate; @Autowired DefaultValueProperties defaultValueProperties; @Autowired PasswordEncoder passwordEncoder; @Override public User createUser(UserRegisterRequest userRegisterRequest) { String encodePassword = passwordEncoder.encode(userRegisterRequest.getPassword()); User user = new User(userRegisterRequest.getName(), userRegisterRequest.getEmail(), encodePassword); LabelSetting labelSetting = new LabelSetting(); labelSetting.setFirstColor(defaultValueProperties.getFirstColor()); labelSetting.setSecondColor(defaultValueProperties.getSecondColor()); labelSetting.setThirdColor(defaultValueProperties.getThirdColor()); labelSetting.setFourthColor(defaultValueProperties.getFourthColor()); user.setLabelSetting(labelSetting); return mongoTemplate.insert(user, "users"); } ... }
下一篇從自定義登入 API 接著寫~
延伸閱讀
參考資料
- 《極速開發 Java大型系統:Spring Boot 又輕又快又好學》
- 三更草堂 Spring Security 框架教程
- 【Spring Boot】第17課-Spring Security 的驗證與授權