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。
    Info
    ThreadLocal
    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

  • 作為封裝使用者資料的物件,包含使用者名稱、密碼、權限等。

延伸閱讀

參考資料

comments

comments powered by Disqus