系列導航
SpringSecurity系列
- SpringSecurity系列學習(一):初識SpringSecurity
- SpringSecurity系列學習(二):密碼驗證
- SpringSecurity系列學習(三):認證流程和源碼解析
- SpringSecurity系列學習(四):基於JWT的認證
- SpringSecurity系列學習(四-番外):多因子驗證和TOTP
- SpringSecurity系列學習(五):授權流程和源碼分析
- SpringSecurity系列學習(六):基於RBAC的授權
SpringSecurityOauth2系列
- SpringSecurityOauth2系列學習(一):初認Oauth2
- SpringSecurityOauth2系列學習(二):授權服務
- SpringSecurityOauth2系列學習(三):資源服務
- SpringSecurityOauth2系列學習(四):自定義登陸登出接口
- SpringSecurityOauth2系列學習(五):授權服務自定義異常處理
多因子驗證
這一節屬於話題外,討論一下多因子認證
單純的用戶名/密碼登陸在某些時候還是不夠安全的,因為大部分用戶會在各種平台采用同樣的密碼,只要有一個平台發生泄漏,那么很可能會影響其他平台,這要有一個平台發生泄漏,那么很可能影響其他平台,所以增加一步或者多步驗證成了目前較流行的方案。
雙因子登陸是最基礎的,也是用戶體驗最好的。比如登陸的時候郵件或者短信驗證,比如指紋,人臉認證等。
TOTP
基於時間的一次性密碼
- 一次性:
-
在多步驗證中,通常會在第二步采用隨機密碼,這個密碼一般情況下是一個一次性密碼,也就是驗證之后就拋棄掉了,不允許進行多次使用同一個密碼進行驗證。
-
但這存在一個問題,如果一直允許用戶輸入新的密碼進行驗證,這等於給了惡意用戶不斷嘗試的機會。
-
- 時間性: 為了解決上面的問題,提出了時間性 -> 這個一次性驗證碼是有時效期的。而且在有效期內,這個密碼的生成應該一致的。過了這個時間之后,密碼過期,不能進行認證。這就是基於時間的一次性密碼。它是用一種以當前時間作為輸入的算法生成的。
引入依賴
<properties>
...
<otp.version>0.2.0</otp.version>
...
</properties>
...
<!-- Java OTP 依賴 -->
<dependency>
<groupId>com.eatthepath</groupId>
<artifactId>java-otp</artifactId>
<version>${otp.version}</version>
</dependency>
...
工具類
import com.eatthepath.otp.TimeBasedOneTimePasswordGenerator;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import javax.crypto.KeyGenerator;
import javax.crypto.spec.SecretKeySpec;
import java.security.InvalidKeyException;
import java.security.Key;
import java.security.NoSuchAlgorithmException;
import java.time.Duration;
import java.time.Instant;
import java.util.Base64;
import java.util.Optional;
/**
* Totp工具類
* 用於一次性驗證碼
*
* @author 硝酸銅
* @date 2021/9/22
*/
@Slf4j
@Component
public class TotpUtil {
/**
* 密碼有效期,在有效期內,生成的所有密碼都一樣
*/
private static final long TIME_STEP = 60 * 5L;
/**
* 密碼長度
*/
private static final int PASSWORD_LENGTH = 6;
private KeyGenerator keyGenerator;
private TimeBasedOneTimePasswordGenerator totp;
/*
* 初始化代碼塊,Java 8 開始支持。這種初始化代碼塊的執行在構造函數之前
* 准確說應該是 Java 編譯器會把代碼塊拷貝到構造函數的最開始。
*/
{
try {
totp = new TimeBasedOneTimePasswordGenerator(Duration.ofSeconds(TIME_STEP), PASSWORD_LENGTH);
keyGenerator = KeyGenerator.getInstance(totp.getAlgorithm());
// SHA-1 and SHA-256 需要 64 字節 (512 位) 的 key; SHA512 需要 128 字節 (1024 位) 的 key
keyGenerator.init(512);
} catch (NoSuchAlgorithmException e) {
log.error("沒有找到算法 {}", e.getLocalizedMessage());
}
}
/**
* @param time 用於生成 TOTP 的時間
* @return 一次性驗證碼
* @throws InvalidKeyException 非法 Key 拋出異常
*/
public String createTotp(final Key key, final Instant time) throws InvalidKeyException {
String format = "%0" + PASSWORD_LENGTH + "d";
return String.format(format, totp.generateOneTimePassword(key, time));
}
public Optional<String> createTotp(final String strKey) {
try {
return Optional.of(createTotp(decodeKeyFromString(strKey), Instant.now()));
} catch (InvalidKeyException e) {
return Optional.empty();
}
}
/**
* 驗證 TOTP
*
* @param code 要驗證的 TOTP
* @return 是否一致
* @throws InvalidKeyException 非法 Key 拋出異常
*/
public boolean validateTotp(final Key key, final String code) throws InvalidKeyException {
Instant now = Instant.now();
return createTotp(key, now).equals(code);
}
public Key generateKey() {
return keyGenerator.generateKey();
}
public String encodeKeyToString(Key key) {
return Base64.getEncoder().encodeToString(key.getEncoded());
}
public String encodeKeyToString() {
return encodeKeyToString(generateKey());
}
public Key decodeKeyFromString(String strKey) {
return new SecretKeySpec(Base64.getDecoder().decode(strKey), totp.getAlgorithm());
}
public long getTimeStepInLong() {
return TIME_STEP;
}
public Duration getTimeStep() {
return totp.getTimeStep();
}
}
寫個main
方法調用一下
public static void main(String[] args) {
TotpUtil util = new TotpUtil();
String key = "vVgYlufzmEn0yJwDWYjEmyI6tY1UitlywKbOv8nM1nLioDzZEFCedK8+g1YwsDsA0n9vLC/4skzJE6EYQBSIXw==";
try {
String code = util.createTotp(util.decodeKeyFromString(key), Instant.now());
System.out.println(code);
System.out.println(util.validateTotp(util.decodeKeyFromString(key),code));
} catch (InvalidKeyException e) {
e.printStackTrace();
}
}
>>
606187
true
需要注意的是,這種驗證碼的key和JWT的key是不相同的。
JWT的key是整個系統共用的,而驗證碼的key應該是基於用戶的不同而不同。
試想一下,如果整個系統共用一個驗證碼key,整個系統在同一段內時間發布的驗證碼都會一樣,會帶了很大的安全隱患。
多因子用戶認證邏輯
- 用戶登陸,根據User表中設定的屬性字段,比如usingMfa,來決定是否啟用多因子驗證流程,或者判斷上次用戶登陸的IP與這一次登陸的IP差異較大,則需要進行多因子認證
- 在數據庫中調出這個用戶的key,生成TOTP
- 服務端這個時候其實應該返回一個認證失敗的響應,因為如果用戶需要進行多因子認證,那么用戶名密碼登陸成功,只是認證中一個字環節成功,整個認證過程還沒有成功。為了區分和用戶名密碼認證失敗的響應區分開,我們在相應頭中加入
X-Authenticate:mfa,reamlm=請求id
,這個根據業務需求定義 - 客戶端判斷響應頭是否有
X-Authenticate:mfa,reamlm=請求id
,根據用戶的選擇決定是短信發送還是電子郵件發送,進入不同的頁面去進行短信或者電子郵箱驗證 - 用戶進行多因子驗證,完成認證,服務端生成JTW給客戶端
多因子認證的邏輯已經有了,
關於怎么發短信和郵件,這里不多做闡述
主要邏輯代碼:
/**
* Jwt認證過濾器
* @author 硝酸銅
* @date 2021/7/1
*/
public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter {
...
/**
* 認證成功邏輯
*/
@Override
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException {
//判斷 是否需要多因子認證,如果是,則將用戶信息放入緩存中,返回id
User user = userService.getByUsername(authResult.getName());
if(user.isUsingMfa()){
//將用戶信息存到緩存
Integer cacheId = cacheService.cache(user);
try {
//登錄成功時,返回json格式進行提示
response.setContentType("application/json;charset=utf-8");
response.setStatus(HttpServletResponse.SC_OK);
//為了區分用戶名和密碼認證失敗的響應區分開,添加響應頭
response.addHeader("X-Authenticate","mfa,reamlm=" + cacheId);
PrintWriter out = response.getWriter();
Map<String, Object> map = new HashMap<>();
map.put("code", HttpServletResponse.SC_UNAUTHORIZED);
map.put("message", "賬號密碼認證成功,請進行下一步認證");
out.write(new ObjectMapper().writeValueAsString(map));
out.flush();
out.close();
} catch (Exception e1) {
e1.printStackTrace();
}
return;
}
//如果不需要,則直接返回token
//令牌私鑰
PrivateKey accessPrivateKey = null;
PrivateKey refreshPrivateKey = null;
...
}
...
}
怎么緩存看業務需求,微服務的話使用redis,如果是體量小的單體應用的話可以考慮使用caffeine,這里就不做闡述了。
接下來的邏輯就簡單了,前端判斷請求頭,判斷賬號密碼認證成功,然后調用多因子認證的接口,參數傳入是短信還是郵箱。服務端接到請求后,根據傳入的參數,從緩存中拿出用戶信息,然后根據用戶的totp key,去生成一個totp驗證碼,發送出去。
用戶從郵件或者短信里面輸入驗證碼,前端調用驗證接口。服務器接到請求,通過id從緩存中拿用戶信息,然后進行驗證(驗證方式是驗證入參驗證碼和使用用戶key生成的totp驗證碼是否一樣,在設置時間內,同一個key生成的驗證碼是相同的),如果相同,則返回token,不相同則返回報錯