Shiro+Redis實現登入次數凍結的示例
概述
假設我們需要有這樣一個場景:如果使用者連續輸錯5次密碼,那可能說明有人在搞事情,所以需要暫時凍結該賬戶的登入功能
關於Shiro整合JWT,可以看這裡:Springboot實現Shiro+JWT認證
假設我們的專案中用到了shiro,因為Shiro是建立在完善的介面驅動設計和麵向物件原則之上的,支援各種自定義行為,所以我們可以結合Shiro框架的認證模組和redis來實現這個功能。
思路
我們大體的思路如下:
- 使用者登入
- Shiro去Redis檢查賬戶的登入錯誤次數是否超過規定範圍(超過了就是所謂的凍結)
- Shiro進行密碼比對
- 如果登入失敗,則去Redis裡記錄:登入錯誤次數+1
- 如果密碼正確,則登入成功,刪除Redis裡的登入錯誤記錄
前期準備
除了需要用到Shiro以外,我們也需要用到Redis,這裡需要先配置好RedisTemplate,(由於這個不是重點,我就把程式碼和配置方法貼在文章的最後了),另外,在Controller層,登入介面的異常處理除了之前的登入錯誤,還需要新增一個賬戶凍結類的異常,程式碼如下:
@PostMapping(value = "/login") public AccountVO login(String userName,String password){ //嘗試登入 Subject subject = SecurityUtils.getSubject(); try { //通過shiro提供的安全介面來進行認證 subject.login(new UsernamePasswordToken(userName,password)); } catch (ExcessiveAttemptsException e1) { //新增一個賬戶鎖定類錯誤 throw new AccountLockedException(); } catch (Exception e) { //其他的錯誤判定 throw new LoginFailed(); } //聚合登入資訊 AccountVO account = accountService.getAccountByUserName(userName); //返回正確登入的結果 return account; }
自定義Shiro認證管理器
HashedCredentialsMatcher
當你在上面的Controller層呼叫subject.login方法後,會進入到自定義的Realm裡去,然後慢慢進入到Shiro當前的Security Manager裡定義的HashedCredentialsMatcher認證管理器的doCredentialsMatch方法,進行密碼匹配,原版程式碼如下:
/** * This implementation first hashes the {@code token}'s credentials,potentially using a * {@code salt} if the {@code info} argument is a * {@link org.apache.shiro.authc.SaltedAuthenticationInfo SaltedAuthenticationInfo}. It then compares the hash * against the {@code AuthenticationInfo}'s * {@link #getCredentials(org.apache.shiro.authc.AuthenticationInfo) already-hashed credentials}. This method * returns {@code true} if those two values are {@link #equals(Object,Object) equal},{@code false} otherwise. * * @param token the {@code AuthenticationToken} submitted during the authentication attempt. * @param info the {@code AuthenticationInfo} stored in the system matching the token principal * @return {@code true} if the provided token credentials hash match to the stored account credentials hash,* {@code false} otherwise * @since 1.1 */ @Override public boolean doCredentialsMatch(AuthenticationToken token,AuthenticationInfo info) { Object tokenHashedCredentials = hashProvidedCredentials(token,info); Object accountCredentials = getCredentials(info); return equals(tokenHashedCredentials,accountCredentials); }
可以發現,原版的邏輯很簡單,就做了兩件事,獲取密碼,比對密碼。
由於我們需要聯動Redis,在每次登入前都做一次凍結檢查,每次遇到登入失敗之後還需要實現對redis的寫操作,所以現在需要重寫一個認證管理器去配置到Security Manager裡。
CustomMatcher
我們自定義一個CustomMatcher,這個類繼承了HashedCredentialsMatcher,唯獨重寫了doCredentialsMatch方法,在這裡面加入了我們自己的邏輯,程式碼如下:
import com.imlehr.internship.redis.RedisStringService; import org.apache.shiro.authc.AuthenticationInfo; import org.apache.shiro.authc.AuthenticationToken; import org.apache.shiro.authc.ExcessiveAttemptsException; import org.apache.shiro.authc.UsernamePasswordToken; import org.apache.shiro.authc.credential.HashedCredentialsMatcher; import org.springframework.beans.factory.annotation.Autowired; /** * @author Lehr * @create: 2020-02-25 */ public class CustomMatcher extends HashedCredentialsMatcher { //這個是redis裡的key的統一字首 private static final String PREFIX = "USER_LOGIN_FAIL:"; @Autowired RedisStringService redisUtils; @Override public boolean doCredentialsMatch(AuthenticationToken token,AuthenticationInfo info) { //檢查本賬號是否被凍結 //先獲取使用者的登入名字 UsernamePasswordToken myToken = (UsernamePasswordToken) token; String userName = myToken.getUsername(); //初始化錯誤登入次數 Integer errorNum = 0; //從資料庫裡獲取錯誤次數 String errorTimes = (String)redisUtils.get(PREFIX+userName); if(errorTimes!=null && errorTimes.trim().length()>0) { //如果得到的字串不為空不為空 errorNum = Integer.parseInt(errorTimes); } //如果使用者錯誤登入次數超過十次 if (errorNum >= 10) { //丟擲賬號鎖定異常類 throw new ExcessiveAttemptsException(); } //先按照父類的規則來比對密碼 boolean matched = super.doCredentialsMatch(token,info); if(matched) { //清空錯誤次數 redisUtils.remove(PREFIX+userName); } else{ //新增一次錯誤次數 秒為單位 redisUtils.set(PREFIX+userName,String.valueOf(++errorNum),60*30L); } return matched; } }
首先,我們從AuthenticationToken裡面拿到之前存入的使用者的登入資訊,這個物件其實就是你在Controller層
subject.login(new UsernamePasswordToken(userName,password));
這一步裡面你例項化的物件
然後,通過使用者的登入名加上固定字首(為了防止防止userName和其他主鍵衝突)去Redis裡獲取到錯誤次數。判斷賬戶是否被凍結的邏輯其實就是看當前使用者的錯誤登入次數是否超過某個規定值,這裡我們定為5次。
接下來,說明使用者沒有被凍結,可以執行登入操作,所以我們就直接呼叫父類的驗證方法來進行密碼比對(就是之前提到的那三行程式碼),得到密碼的比對結果
如果比對一致,那麼就成功登入,返回true即可,也可以選擇一旦登入成功,就消除所有錯誤次數記錄,上面的程式碼就是這樣做的。
如果對比結果不一樣,那就再新增一次錯誤記錄,然後返回false
測試
第一次登入:頁面結果:
Redis中:
然後連續錯誤10次:
頁面結果:
Redis中:
然後等待了半小時之後(其實我調成了5分鐘)
再次嘗試錯誤密碼登入:
再次報錯,此時Redis裡由於之前的記錄到期了,自動銷燬了,所以再次觸發錯誤又會新增一次錯誤記錄
現在嘗試一次正確登入:
成功登入
檢視Redis:
🎉Done!
附RedisTemplate程式碼
配置類
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.data.redis.connection.RedisConnectionFactory; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.serializer.JdkSerializationRedisSerializer; import org.springframework.data.redis.serializer.StringRedisSerializer; @Configuration public class RedisConfig { @Bean public RedisTemplate<String,Object> redisTemplate(RedisConnectionFactory redisConnectionFactory) { //我就用的預設的序列化處理器 StringRedisSerializer stringRedisSerializer = new StringRedisSerializer(); JdkSerializationRedisSerializer ser = new JdkSerializationRedisSerializer(); RedisTemplate<String,Object> template = new RedisTemplate<String,Object>(); template.setConnectionFactory(redisConnectionFactory); template.setKeySerializer(stringRedisSerializer); template.setValueSerializer(ser); return template; } @Bean public RedisStringService myStringRedisTemplate() { return new RedisStringService(); } }
工具類RedisStringService
一個只能用來處理Value是String的工具類,就是我在CustomMatcher裡Autowired的這個類
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.data.redis.core.ValueOperations; import org.springframework.stereotype.Service; import java.util.concurrent.TimeUnit; public class RedisStringService { @Autowired protected StringRedisTemplate redisTemplate; /** * 寫入redis快取(不設定expire存活時間) * @param key * @param value * @return */ public boolean set(final String key,String value){ boolean result = false; try { ValueOperations operations = redisTemplate.opsForValue(); operations.set(key,value); result = true; } catch (Exception e) { e.getMessage(); } return result; } /** * 寫入redis快取(設定expire存活時間) * @param key * @param value * @param expire * @return */ public boolean set(final String key,String value,Long expire){ boolean result = false; try { ValueOperations operations = redisTemplate.opsForValue(); operations.set(key,value); redisTemplate.expire(key,expire,TimeUnit.SECONDS); result = true; } catch (Exception e) { e.getMessage(); } return result; } /** * 讀取redis快取 * @param key * @return */ public Object get(final String key){ Object result = null; try { ValueOperations operations = redisTemplate.opsForValue(); result = operations.get(key); } catch (Exception e) { e.getMessage(); } return result; } /** * 判斷redis快取中是否有對應的key * @param key * @return */ public boolean exists(final String key){ boolean result = false; try { result = redisTemplate.hasKey(key); } catch (Exception e) { e.getMessage(); } return result; } /** * redis根據key刪除對應的value * @param key * @return */ public boolean remove(final String key){ boolean result = false; try { if(exists(key)){ redisTemplate.delete(key); } result = true; } catch (Exception e) { e.getMessage(); } return result; } /** * redis根據keys批量刪除對應的value * @param keys * @return */ public void remove(final String... keys){ for(String key : keys){ remove(key); } } }
到此這篇關於Shiro+Redis實現登入次數凍結的文章就介紹到這了,更多相關Shiro+Redis登入凍結內容請搜尋我們以前的文章或繼續瀏覽下面的相關文章希望大家以後多多支援我們!