前后端分離后,由於沒有了session,導致驗證碼內容存儲在session已經不可能了,因此考慮存儲在redis。本文將介紹一種基於cookie + redis方案的驗證碼
一、方案的提出
(1)驗證碼存放位置
沒了session,則存儲在redis,why?
因為redis 具有key自動過期,所以用來存放驗證碼最為合適
(2)驗證碼的生成及校驗
- 方案一
- 網上比較常見的方案,公司婦幼系統也在用的方案
- 生成階段:每次調用驗證碼接口,生成一個唯一key,value為驗證碼的值,然后存放redis;返回前端時,不再是一張圖片,而是json,內含唯一Key + 驗證碼圖片base64
- 校驗階段:請求后台校驗時,需要帶上這個key + 驗證碼的value
- 優點:實現代碼簡單
- 缺點:如果用戶惡意的頻繁大量調用驗證碼接口,由於舊的驗證碼存儲在redis的key沒有立即刪除,redis中驗證碼key會堆積(雖然有過期,但也頂不住大量並發生成);需要設置額外的限流攔截
- 方案二
- 參考開源項目 https://captcha.anji-plus.com/#/doc
- 生成階段:依賴前端vue組件,生成一個瀏覽器全局的clientUid,傳給后端去生成驗證碼;后端redis存儲key為clientUid,value為驗證碼值,返回json,內含clientUid+圖片base64
- 校驗階段:請求后台時,需要帶上這個clientUid+ 驗證碼的value
- 優點:完美解決了方案一帶來的用戶惡意刷新驗證碼導致redis中驗證碼堆積的問題
- 不足:比較依賴前端生成的clientUid,如果能夠不依賴這個前端的clientUid就更好了
- 方案三
- 參考方案二思路,但是用了cookie技術
- 生成階段:客戶端唯一標識clientUid不需要需要前端,而是以cookie的方式寫入到瀏覽器,如果瀏覽器已經有該cookie,則以瀏覽器cookie有值為准。這樣這個客戶端唯一標識就不依賴前端了;后端redis存儲key為clientUid,value為驗證碼值;返回前端時可以直接返回圖片,response加一個addCookie的操作
- 校驗階段:從cookie中讀取到客戶端唯一標識,然后去redis中取驗證碼對應的值進行內容比對即可
- 優點:完善了方案二的不足,同時如果從session的項目改造成token時,該方案的前端改動最少
- 不足:依賴cookie,不適合無cookie場景
本次恰好參與了一個從session改成token的前后端分離項目,驗證碼直接用的方案三
二、實現代碼
(1)驗證碼的生成
Controller層
/**
* 獲取用戶登錄圖形驗證碼
*
* @param request
* @param response
*/
@ApiOperation(value = "獲取圖形驗證碼")
@GetMapping("verifyCode")
public void verifyCode(@ApiIgnore HttpServletRequest request, @ApiIgnore HttpServletResponse response) {
// 從cookie中獲取驗證碼對應的唯一key
String verifyKey = Optional.ofNullable(WebUtils.getCookie(request,
WebSecurityConfiguration.USER_LOGIN_VERIFY_CODE_COOKIE_NAME))
.map(Cookie::getValue).orElse(null);
if (StringUtils.isBlank(verifyKey)) {
verifyKey = UUID.randomUUID().toString().replace("-", "");
}
// 這里每個請求都add新cookie,如果不每次add,則有可能會導致 cookie的path發生變化
response.addCookie(CookieHelper.generateCookie(WebSecurityConfiguration.USER_LOGIN_VERIFY_CODE_COOKIE_NAME,
verifyKey, "/", request));
// 生成隨機字串,用來做驗證碼
String verifyCode = VerifyCodeUtils.generateVerifyCode(4);
try {
// 生成圖片
int width = 100;
int height = 40;
response.setHeader("Pragma", "No-cache");
response.setHeader("Cache-Control", "no-cache");
response.setDateHeader("Expires", 0);
response.setContentType("image/jpeg");
VerifyCodeUtils.outputImage(width, height, response.getOutputStream(), verifyCode);
} catch (IOException e) {
log.error("生成用戶登錄圖形驗證碼出錯:", e);
throw new SPIException(BasicEcode.FAILED);
}
// 存入會話redis, 2分鍾內有效
redisService.set(RedisConstant.VERIFY_CODE_KEY_PREFIX + verifyKey, verifyCode, 120, TimeUnit.SECONDS);
}
cookie工具類
public class CookieHelper {
private CookieHelper() {
}
public static Cookie generateCookie(String name, String value, String path, HttpServletRequest request) {
Cookie cookie = new Cookie(name, value);
// 這個path的寫法參考SpringBoot源碼寫的
cookie.setPath(StringUtils.isBlank(path) ? getRequestContext(request) : path);
cookie.setSecure(false);
cookie.setHttpOnly(true);
// 設置為-1時,關閉瀏覽器自動失效,設置為0馬上失效
cookie.setMaxAge(-1);
return cookie;
}
public static void addCookie(String name, String value, HttpServletRequest request, HttpServletResponse response) {
response.addCookie(generateCookie(name, value, null, request));
}
/**
* 設置全局cookie,相同域名下不同項目都可以訪問
* @param name cookie名稱
* @param value cookie值
* @param request
* @param response
*/
public static void addGlobalCookie(String name, String value, HttpServletRequest request, HttpServletResponse response){
response.addCookie(generateCookie(name, value, "/", request));
}
/**
* description: 刪除cookie
* @param name cookie名字
* @param request
* @param response
* @return void
* @author ZENG.XIAO.YAN
* @time 2021-07-23 13:57
*/
public static void deleteCookie(String name, HttpServletRequest request, HttpServletResponse response) {
Cookie cookie = new Cookie(name, null);
cookie.setPath(getRequestContext(request));
cookie.setMaxAge(0);
response.addCookie(cookie);
}
/**
* 刪除全局cookie
* @param name
* @param request
* @param response
*/
public static void deleteGlobalCookie(String name, HttpServletRequest request, HttpServletResponse response) {
Cookie cookie = new Cookie(name, null);
cookie.setPath("/");
cookie.setMaxAge(0);
response.addCookie(cookie);
}
private static String getRequestContext(HttpServletRequest request) {
String contextPath = request.getContextPath();
return contextPath.length() > 0 ? contextPath : "/";
}
}
(2)檢驗相關代碼
直接上代碼
// 校驗圖形驗證碼
// 1.從cookie中取出驗證碼的key
Cookie cookie = WebUtils.getCookie(request, WebSecurityConfiguration.USER_LOGIN_VERIFY_CODE_COOKIE_NAME);
String verifyKey = Optional.ofNullable(cookie).map(Cookie::getValue).orElse(null);
if (StringUtils.isBlank(verifyKey)) {
// 沒有key,直接提示過期
CustomResponse.error(request, response, PlatformExceptionCode.VERIFY_CODE_EXPIRED);
return null;
}
// 2.從redis中拿到對應key的數據
String redisKey = RedisConstant.VERIFY_CODE_KEY_PREFIX + verifyKey;
String redisVerifyCode = (String) redisService.get(redisKey);
if (StringUtils.isBlank(redisVerifyCode)) {
CustomResponse.error(request, response, PlatformExceptionCode.VERIFY_CODE_EXPIRED);
return null;
}
// 3.比較值
if (!redisVerifyCode.equalsIgnoreCase(authenticationBean.getVerifyCode())) {
CustomResponse.error(request, response, PlatformExceptionCode.VERIFY_CODE_ERROR);
return null;
}
// 圖形驗證碼校驗成功后,直接從會話中移除
redisService.delete(redisKey);
三、小結
cookie +redis的方式的驗證碼特別適合那種從session改造成token的前后端分離項目