To-Do Calendar - Day27 Spring Security 的認證與授權(二)
前面已經自定義 UserDetailsService,在類中實作了去數據庫查詢使用者資料,接下來要實作自定義登入 API。
登入 API 細節分析
- 定義一個登入的 Controller,Controller 去調用 Service,然後讓 Spring Security 對這個 API 放行。
- 在 Service 當中可以使用 AuthenticationManager 的 authenticate 方法去進行認證使用者認證,所以需要在 SecurityConfig 中配置將 AuthenticationManager 注入容器。
- 認證成功的話要生成一個 jwt,放入響應中返回。為了讓使用者下次請求時能通過 jwt 識別身分,我們需要把使用者資料存入 Redis。
實作 login API
修改之前寫的簡易版的 login API。
- Request URL
POST http://localhost:8080/users/login
- Request Body
{ "email":"wowo@gmail.com", "password":"wowo1121" }
- Response Body
{ "id": "62b1361d521d677941408237", "name": "wowo", "email": "wowo@gmail.com", "token": "eyJhbGciOiJIUzI1NiJ9.eyJqdGkiOiJkYmNlNTk2NWM5NmY0ZTg3YWNkZGY5YWM0ZGU4YTZjMyIsInN1YiI6IjYyYjEzNjFkNTIxZDY3Nzk0MTQwODIzNyIsImlzcyI6InRvZG9DYWxlbmRhciIsImlhdCI6MTY1Njc0MTc2OCwiZXhwIjoxNjU2NzQ1MzY4fQ.VciDkHL8SEfll3gAlZ1OBQOjNm2sKD6RyEffoQ2i3K4", "labelSetting":{ "firstColor": "#ff004c", "secondColor": "#f5b8de", "thirdColor": "#f0d9a8", "fourthColor": "#68dddf" }, "createdTime": "2022-06-21T11:08:13.647+08:00", "lastModifiedTime": "2022-06-24T12:05:16.372+08:00" }
實作 Controller 層
- Response Body 要多帶 token 參數
@RestController public class UserController { @Autowired private UserService userService; @PostMapping("/users/login") public ResponseEntity<UserLoginResponse> login(@RequestBody UserLoginRequest userLoginRequest) { UserLoginResponse user = userService.login(userLoginRequest); return ResponseEntity.status(HttpStatus.OK).body(user); } ... }
設定 Security Config
配置 AuthenticationManager
- 覆寫 authenticationManagerBean 方法,並使用
@Bean
註解將 AuthenticationManager 注入到 Spring 容器中@Configuration public class SecurityConfig extends WebSecurityConfigurerAdapter { @Bean @Override public AuthenticationManager authenticationManagerBean() throws Exception { return super.authenticationManagerBean(); } @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } }
配置許可權控制規則
- 覆寫 configure(HttpSecurity http)方法,進行許可權控制規則的相關設定(對登入與註冊接口放行)
@Configuration public class SecurityConfig extends WebSecurityConfigurerAdapter { protected void configure(HttpSecurity http) throws Exception { http // 關閉CSRF .csrf().disable() // 不通過 Session 取得 SecurityContext .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) .and() .authorizeRequests() // 對登入接口允許匿名訪問 .antMatchers("/users/login").anonymous() // 對註冊接口允許匿名訪問 .antMatchers("/users/register").anonymous() // 剩下尚未匹配到的 URL 都需要身份驗證 .anyRequest().authenticated(); } @Bean @Override public AuthenticationManager authenticationManagerBean() throws Exception { return super.authenticationManagerBean(); } @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } }
HttpSecurity 提供的方法
- anonymous():允許匿名訪問(登入不允許)
- denyAll():不可訪問
- permitAll():所有使用者可訪問(指定某些 URL 不受保護),一般針對靜態資源和註冊等未授權情況下需要造訪的頁面
- authorizeRequests():定義那些 URL 需要被保護、那些不需要被保護
- antMatchers():定義匹配的 URL
- hasRole(String role):限制單一角色訪問
- hasAnyRole(String…role):允許多個角色訪問
- http.csrf():設定是否開啟 CSRF 保護,可以開啟之後指定忽略的介面
- anyRequest():會對剩下的 API 定義規則
- authenticated():使用者登入後可訪問
- access(String attribute):該方法可建立複雜的限制
- hasIpAddress(String ipaddressExpression):使用者來自參數中的 IP 可訪問,用於限制 IP 位址或子網
實作 Service 層
- 注入 AuthenticationManager
- 將使用者的用戶名和密碼封裝成 Authentication 物件
- 使用 authenticationManager 的 authenticate 方法進行使用者認證
- 認證通過,使用 userId 生成一個 JWT 並返回
- 把完整的使用者資料存入 Redis
(此處先略過引入 jwt、Redis 與建立工具類的過程)@Component public class UserServiceImpl implements UserService { @Autowired private UserDao userDao; @Autowired private AuthenticationManager authenticationManager; @Autowired private RedisCache redisCache; @Override public UserLoginResponse login(UserLoginRequest userLoginRequest) { // authenticationManager authenticate 進行使用者認證 UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(userLoginRequest.getEmail(), userLoginRequest.getPassword()); Authentication authenticate = authenticationManager.authenticate(authenticationToken); // 認證通過 -> 使用 userId 生成一個 JWT 並返回 LoginUser loginUser = (LoginUser) authenticate.getPrincipal(); User user = loginUser.getUser(); String jwt = JwtUtil.createJWT(user.getId()); // 把完整的使用者信息存入 redis String redisKey = "login:" + user.getId(); redisCache.setCacheObject(redisKey, loginUser); UserLoginResponse userLoginResponse = new UserLoginResponse(user, jwt); return userLoginResponse; } }
Info
Authentication 在認證成功前後的變化:
- 經過認證之後的 Authentication,裡面的值會發生變化,一開始的 principal 儲存的只是用戶名,但是認證之後,principal 所儲存的會是 LoginUser 的物件,這個 LoginUser 是哪來的呢?實際上這是由 UserDetailsServiceImpl 所返回的。
- 登入認證時 Spring Security 會通過 UserDetailsService 的 loadUserByUsername() 方法獲取對應的 UserDetails 進行認證,認證通過後會將該 UserDetails 放到認證通過的 Authentication 的 principal,然後再把該 Authentication 存入到 SecurityContext 中。之後如果需要使用使用者資料,就是通過 SecurityContextHolder 獲取存放在 SecurityContext 中的 Authentication 的 principal。
核心類別
SecurityContext
- SecurityContext 中包含目前正在存取系統的使用者資料,它只有 getAuthentication() 和setAuthentication() 兩個方法。
- SecurityContext 的資料是由 SecurityContextHolder 來處理的。
SecurityContextHolder
- SecurityContextHolder 用來儲存 SecurityContext。
- 預設情況下,SecurityContextHolder 將使用 ThreadLocal 來保存 SecurityContext,這也就意味著在處於同一線程中的方法中我們可以從 ThreadLocal 中獲取到當前的 SecurityContext。
InfoThreadLocal
ThreadLocal 是線程的局部變量, 是每一個線程所單獨持有的,其他線程不能對其進行訪問。
Authentication
- 包含 Principal、Credentials 和 Authorities 的一個物件。
- 用來存放用戶認證資料,在用戶登入認證之前相關資料會封裝為一個 Authentication,在登入認證成功之後又會生成一個資料更全面,包含用戶權限等資料的 Authentication,然後把它保存在 SecurityContextHolder 所持有的 SecurityContext 中,供後續的程序進行呼叫,如訪問權限的鑑定等。
- Authentication.getPrincipal() : 認證之前存放用戶名,認證成功後存放使用者用的 UserDetails
- Authentication.getCredentials() : 認證之前存放密碼,認證成功後通常不帶任何資料
- Authentication.getAuthorities() : 認證之前不帶任何資料,認證成功後存放使用者權限
AuthenticationManager
- AuthenticationManager 是一個用來處理認證(Authentication)請求的介面,裡面只定義了 authenticate() 方法,該方法只接收一個代表認證請求的 Authentication 實例作為參數,如果認證成功,則會返回一個封裝了當前用戶權限等資料的 Authentication 實例進行返回。
ProviderManager
- ProviderManager 會維護一個認證的列表,以便處理不同認證方式的認證,因為系統可能存在多種認證方式,例如手機號碼、電子信箱等。
- 在認證時,如果 ProviderManager 的認證結果不是 null,則說明認證成功,不再進行其他方式的認證,並且作為認證的結果儲存在 SecurityContext 中。
DaoAuthenticationManager
- 是 AuthenticationManager 最常用的實現,用來取得使用者提交的用戶名和密碼,並進行正確性比對,如果正確,則回傳一個查詢到的使用者資料。
UserDetailsService
- 用來載入使用者資訊。該介面的唯一方法是 loadUserByUsername(String username),用來根據用戶名查詢使用者資料,並將查到的使用者資料封裝成 UserDetails 物件返回。
UserDetails
- 作為封裝使用者資料的物件,包含使用者名稱、密碼、權限等。
延伸閱讀
參考資料
- 《極速開發 Java大型系統:Spring Boot 又輕又快又好學》
- 三更草堂 Spring Security 框架教程