Spring Security 的注冊登錄流程


Spring Security 的注冊登錄流程

數據庫字段設計

主要數據庫字段要有:

  • 用戶的 ID

  • 用戶名稱

  • 聯系電話

  • 登錄密碼(非明文)

UserDTO對象

  需要一個數據傳輸對象來將所有注冊信息發送到我們的 Spring Boot 后端,該DTO對象應該要擁有所有我們以后創建User對象的所有字段內容:

public class UserDto {
     private String userName;
   
     private String password;

     private String phone;
   
     // standard getters and setters
}

用戶注冊控制器

  登錄頁面上的“注冊”鏈接會將用戶帶到注冊頁面。該頁面的后端位於注冊控制器中,並映射到 “/user/registration”,或者你可以使用 PostMan 來發送注冊請求到后端,方便測試后端內容。

   @PostMapping("/user/registration")
   public String register(@RequestBody UserDTO userDTO) {
         if (userService.saveUserInfo(userDTO)) {
              logger.info("用戶注冊成功");
              return "注冊成功";
          } else {
              logger.error("用戶注冊失敗");
              return "注冊失敗";
          }
     }

  application/json 這個 Content-Type 作為響應頭大家肯定不陌生。實際上,現在越來越多的人把它作為請求頭,用來告訴服務端消息主體是序列化后的 JSON 字符串。

  當控制器收到請求 “/user/registration” 時,它將創建新的UserDTO對象,該對象將獲取請求頭Content-Type: application/json的輸入流內容,在json_decode 成對象。

定義相關字段驗證

  需要使用正則表達式來驗證注冊的手機號是不是中國的號碼以及各式是不是正確,其中一條正則表達式為:

   private static final String MOBILE_CM_AREA_REX = "^(13[0-9]{9}$|14[0-9]{9}|15[0-9]{9}$|17[0-9]{9}$|18[0-9]{9})$";

  正則表達式的編譯

   private static final Pattern MOBILE_CM_AREA_REX_PATTERN = Pattern
          .compile(MOBILE_CM_AREA_REX);

  調用 Pattern 對象的 matcher 方法來獲得一個 Matcher 對象,對輸入字符串進行解釋和匹配操作

   public static boolean isMobileCM(String mobile) {
         return MOBILE_CM_AREA_REX_PATTERN.matcher(mobile).matches();
   }

  還可以定義其他的驗證,比如說用戶名、密碼格式之類的。

注冊前檢查賬號是否存在

  驗證數據庫中不存在該電子郵件帳戶, 這是在驗證表單之后執行的,也是在UserService的實現的幫助下完成。

  public boolean checkAccountByPhone(UserDTO userDTO) {
    boolean flags;
     ... // check account from database or other ways
     if (檢查出賬戶已存在存在) {
            throw new UserAlreadyExistException(
               "There is an account with that email address: "
               +  userDTO.getPhone());
      }
       return flags;
  }

保留注冊數據並完成表單處理

  在控制器層中實現注冊邏輯,成功后通知前端或者Postman注冊結果。

加載安全性登錄的用戶詳細信息

  之前討論的登錄驗證時使用的是硬編碼憑據。讓我們進行更改,並使用新注冊的用戶信息和憑據。我們將實現一個自定義UserDetailsService,以檢查從持久性層登錄的憑據。

@Service
@Transactional
public class MyUserDetailsService implements UserDetailsService {
 
   @Autowired
   private UserRepository userRepository;
   
   @Override
   public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
       UserDTO userDTO = userRepository.selectOneByUsername(username);
       if (userDTO == null) {
           logger.warn("用戶" + username + "不存在");
           throw new UsernameNotFoundException("用戶" + username + "不存在");
      }
     
  userDTO.setAuthorities(AuthorityUtils.commaSeparatedStringToAuthorityList(userDTO.getRoles()));
       return userDTO;
  }
}

  loadUserByUsername這個函數返回的是一個完全填充的用戶記錄UserDetail對象,為用戶加載信息的最常見方法。UserDetails將用於構建Authentication存儲在中的對象SecurityContextHolder。那這個函數什么時候被調用呢?(參考1參考2參考3

  1. 它通常由AuthenticationProvider實例調用,以認證用戶。例如,提交用戶名和密碼后,將UserdetailsService被調用來查找該用戶的密碼以查看其是否正確。通常,它還將提供有關用戶的其他信息,例如權限和你可能希望為已登錄用戶(例如電子郵件)訪問的任何自定義字段。那是主要的使用模式。關於UserDetailsService經常會有一些困惑。它純粹是用於用戶數據的DAO層,除了將數據提供給框架內的其他組件外,不執行其他功能。特別是,它不對用戶進行身份驗證,這由AuthenticationManager完成。在許多情況下,如果您需要自定義身份驗證過程,則直接實現AuthenticationProvider更有意義。

  2. 用戶通過身份驗證后,會將SecurityContext實例存儲在會話中。根據應用程序的類型,可能需要制定一種策略來存儲用戶操作之間的SecurityContext。在典型的Web應用程序中,用戶登錄一次,然后通過其會話ID進行標識。服務器緩存持續時間會話的主體信息。在Spring Security中,請求之間存儲SecurityContext 的責任落在SecurityContextPersistenceFilter,默認情況下,HTTP請求之間將上下文存儲為HttpSession的屬性。

  3. 如果你需要實現自定義UserDetailsService,則將取決於您的要求及其存儲方式。通常,你將在與其他用戶信息同時加載它們。你可能不會在過濾器中執行此操作。如上述參考手冊中的引用所述,如果你·實際上要實現其他身份驗證機制,則應直接實現AuthenticationProvider。你的應用程序中沒有是強制性的要有UserDetailsService,可以將其視為某些內置功能使用的策略。

啟用新的身份驗證提供程序

  為了能夠在 Spring Security 配置新的用戶服務,我們只需要添加一個引用到一個UserDetailsService內部認證管理元素,並添加了一個UserDetailsService的bean:

@Autowired
private MyUserDetailsService userDetailsService;

@Override
protected void configure(AuthenticationManagerBuilder auth)
 throws Exception {
   auth.userDetailsService(userDetailsService);
}

添加對用戶認證的自定義AuthenticationProvider

@Autowired
   private AuthenticationProvider provider;

@Override
   protected void configure(AuthenticationManagerBuilder auth) throws Exception {
       auth.authenticationProvider(provider).userDetailsService(vbUserDetailService);
  }

使用BCrypt加密算法

  注冊過程的關鍵部分- 密碼編碼 -基本上不以明文形式存儲密碼。

  在配置中將簡單的BCryptPasswordEncoder定義為bean開始。

  @Bean
   public PasswordEncoder passwordEncoder() {
       return PasswordEncoderFactories.createDelegatingPasswordEncoder();
  }

  以下PasswordEncoderFactories會默認使用BCryptPasswordEncoder編碼

public static PasswordEncoder createDelegatingPasswordEncoder() {
  String encodingId = "bcrypt";
  Map<String, PasswordEncoder> encoders = new HashMap<>();
  encoders.put(encodingId, new BCryptPasswordEncoder());
  encoders.put("ldap", new org.springframework.security.crypto.password.LdapShaPasswordEncoder());
  encoders.put("MD4", new org.springframework.security.crypto.password.Md4PasswordEncoder());
  encoders.put("MD5", new org.springframework.security.crypto.password.MessageDigestPasswordEncoder("MD5"));
  encoders.put("noop", org.springframework.security.crypto.password.NoOpPasswordEncoder.getInstance());
  encoders.put("pbkdf2", new Pbkdf2PasswordEncoder());
  encoders.put("scrypt", new SCryptPasswordEncoder());
  encoders.put("SHA-1", new org.springframework.security.crypto.password.MessageDigestPasswordEncoder("SHA-1"));
  encoders.put("SHA-256", new org.springframework.security.crypto.password.MessageDigestPasswordEncoder("SHA-256"));
  encoders.put("sha256", new org.springframework.security.crypto.password.StandardPasswordEncoder());
  encoders.put("argon2", new Argon2PasswordEncoder());

  return new DelegatingPasswordEncoder(encodingId, encoders);
}

  BCrypt 會在內部生成隨機鹽。因為這意味着每個調用都會有不同的結果,因此我們只需要對密碼進行一次編碼。注意,即使是相同密碼明文,兩次調用編碼后得到的結果也不是一樣的。

  BCrypt 把密碼編碼后通常長這樣子:

  {bcrypt}$2a$10$BQ2AivawsVvTmnkzETQ6s.OAcHuafwsCJ9e6x0ScHybWlY7Xh1QlC

  BCrypt算法會生成長度為60的字符串,因此我們需要確保密碼將存儲在可以容納該密碼的列中。一個常見的錯誤是創建不同長度的列,然后在身份驗證時收到“ 無效的用戶名或密碼”錯誤。

  注冊時進行編碼:

  user.setPassword(passwordEncoder.encode(userDTO.getPassword()));

  BCrypt算法會生成長度為60的字符串,因此我們需要確保密碼將存儲在可以容納該密碼的列中。一個常見的錯誤是創建不同長度的列,然后在身份驗證時收到“ 無效的用戶名或密碼”錯誤。

  把密碼編碼器加入身份驗證配置中

   @Override
   protected void configure(AuthenticationManagerBuilder auth) throws Exception {
       auth.authenticationProvider(provider).userDetailsService(vbUserDetailService)
              .passwordEncoder(passwordEncoder());
  }

  那么自定義用戶身份驗證中,如何對前端返回的密碼與數據庫中的密碼進行核驗。把明文密碼進行編碼來.equals()?不是這樣,前面說過,每個調用編碼器都會有不同的結果,即使是對相同的明文密碼。可以使用passwordEncoder 里面的matches方法來判斷, 畢竟每次加密相同密碼存進數據庫的都不一樣的。

      String encodePwd = passwordEncoder.encode(password); // 這里僅僅是為了調試的時候驗證每次BCrypt編碼器用的是隨機鹽
      String dbPwd = userInfo.getPassword();
      if (!passwordEncoder.matches(password, dbPwd)) {
           logger.warn("密碼不正確");
           throw new BadCredentialsException("密碼不正確");
      }

  這個matches方法會先對前端傳來的進行相同方式加密的密碼進行判空,然后檢查是不是對應的編碼格式。然后才對前端傳來密碼串和數據庫中的密碼串進行核對,檢查明文密碼是否與數據哈希密碼匹配。具體一點就是說,matches的工作是先檢查dbPwd的格式,使用的編碼器類型,然后再由DelegatingPasswordEncoder轉發給對用類型的BCryptPasswordEncoder來處理,它提取了數據庫中的先前密碼hash過的值中,取出當時hash所用的鹽,然后再把password和這個鹽進行編碼,返回通過一樣的鹽hash出的字符串,最后在進行簡單數組對比。matches方法返回true,表明匹配成功;反之,匹配失敗,拋出認證失敗異常。

public boolean matches(CharSequence rawPassword, String encodedPassword) {
  if (encodedPassword == null || encodedPassword.length() == 0) {
    logger.warn("Empty encoded password");
    return false;
  }

  if (!BCRYPT_PATTERN.matcher(encodedPassword).matches()) {
    logger.warn("Encoded password does not look like BCrypt");
    return false;
  }

  return BCrypt.checkpw(rawPassword.toString(), encodedPassword);
}

上面代碼中相關變量的舉例說明:

前端傳來的密碼password

123

前端編碼后matches方法外的encodePwd

{bcrypt}$2a$10$0wPZ/Gth9qcB6ALJ6XYMs.TffeGBkn/a7EJz0C9IGIVQRzfcek81i

數據庫中先前編碼好的密碼hash串dbPwd

{bcrypt}$2a$10$BQ2AivawsVvTmnkzETQ6s.OAcHuafwsCJ9e6x0ScHybWlY7Xh1QlC

(推測以上hash密碼加粗部分為鹽,鹽的位置是有規律的)


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM