簡述一下項目中手寫的Token驗證服務設計過程java
這裏直接展現整個項目中用到的算法庫,其中涉及位運算的可無論
直接應用到的方法是hash(str)
大概流程以下
1.構造一個大素數表並隨機打亂
2.提供足夠快的快速冪
3.哈希規則:\sum 下標對應byte^^randomPrimes[下標 % 素數表長度] % 128
爲了更快的hash過程其實能夠把下標進一步轉爲其bitcount,這樣算冪會把log的複雜度略降一點web
package com.noresp.oj.utils; /** * 方便OJ搭建的簡易算法庫 * 目前可提供: * 隨機大素數表 * 隨機打亂 * 哈希(注意:特定用途) * 整型交換、bitcount、fastPow * 隨機數 */ public class AlgsUtils { private static final int[] bitmasks = new int[0x100]; private static final int[] randomPrimes = new int[1<<10]; public static final long magicNumber = 19260817L; public static class SimpleRandom { long seed = 1L; public void setSeed(long seed) { this.seed = seed; } /** * 簡易高效的手寫隨機數 * 大概比Math.random快20倍(2^^26數量級下) * @return 隨機數 */ public long next() { seed = seed*1103515245+12345 & 0xffffffffL; // 模擬unsigned int // 切記0xffffffff沒有L會翻車。。 return seed >> 16; } public int next(int mod) { return (int)(next()%mod); } } static { initializeBitmasks(); initializePrimeTable(); randomShuffle(randomPrimes,magicNumber); } /** * O(n)打長度爲n的二進制表 * 測試經過 */ private static void initializeBitmasks() { for(int i = 0xff; i > 0; --i) { if(bitmasks[i] != 0) continue; for(int j = i; j > 0; j -= j&-j) { bitmasks[i]++; } for(int j = i, k = 0; j > 0; j -= j&-j, k++) { bitmasks[j] = bitmasks[i]-k; } } } /** * 計算二進制1的個數 * 測試經過 * @param value * @return */ public static int bitCount(int value) { int result = 0; for(; value > 0; value >>>= 8) { result += bitmasks[value & 0xff]; } return result; } /** * 經過固定的隨機素數進行哈希/加密 * 哈希串 = \sum 下標對應byte^^randomPrimes[下標 % 素數表長度] % 128 * 時間複雜度O(n log2(log2m)),其中n爲字符串長度,m爲最大的隨機素數大小 * @param string * @return */ public static String hash(String string) { if(string == null || string.length() == 0) return null; byte[] b = string.getBytes(); for(int i = 0; i < b.length; i++) { int j = b[i]; int k = randomPrimes[i & randomPrimes.length-1]; // 仿java.util思路 二進制長用&優化取代% k = bitCount(k); b[i] = (byte)fastPow(j,k,0x7f); } return new String(b); //注意不要b.toString() } /** * 快速冪求解a^^n%mod,n不支持負數 * @param a * @param n * @param mod * @return */ public static int fastPow(long a,long n,int mod) { long res = 1; //long防相乘溢出 while(n > 0) { if((n&1) == 1) { res = res*a; if(res >= mod) res %= mod; } a *= a; if(a >= mod) a %= mod; n >>= 1; } return (int)res; } /** * 簡單的篩法初始化素數表 */ private static void initializePrimeTable() { int n = randomPrimes.length << 8; // 一個大概的打表估值,預計素數的大小在1e5數量級 boolean[] notPrime = new boolean[n]; for(int i = 2; i*i < n; i++) { if(!notPrime[i]) { for(int j = i; j < n; j += i) { notPrime[j] = true; } } } // 優先選取大素數,所以倒序處理+插入兩個極大的素數 randomPrimes[0] = (int)1e9+7; randomPrimes[1] = 998244353; for(int i = n-1, j = 2; true; --i) { if(!notPrime[i]) randomPrimes[j++] = i; if(j == randomPrimes.length) return; } } /** * 隨機打亂一個整型數組 * @param toRandom */ public static void randomShuffle(int[] toRandom,long seed) { SimpleRandom roll = new AlgsUtils.SimpleRandom(); roll.setSeed(seed); for(int i = toRandom.length-1; i > 0; --i) { swap(toRandom,i,roll.next(i+1)); } } /** * 交換兩個數,注意安全使用 */ public static void swap(int[] arr,int i,int j) { if(SafeUtils.isOutOfBound(arr,i)) return; if(SafeUtils.isOutOfBound(arr,j)) return; int t = arr[i]; arr[i] = arr[j]; arr[j] = t; } public static void swapRange(int[] arr,int lo,int hi) { while(lo < hi) swap(arr,lo++,hi--); } }
由PART A能夠看到任意編碼的字符串都把char限制在0-127範圍內,但可能存在特殊的轉義符影響面向文本的協議算法
所以須要把0-127映射到ASCII中a-z A-Z 0-9的範圍內數組
爲了知足儘量的均勻分佈,又亂寫了一個算法(其實ch+i是多餘的)安全
public static String visualizableHash(String str) { StringBuilder sb = new StringBuilder(""); for(int i = 0; i < str.length(); i++) { char ch = str.charAt(i); if(isVisualChar(ch)) sb.append(ch); else { char curChar = 'a'; long factor = (int)(ch)*17+i*23; int pos = (int)(factor % (26+26+10)); if(pos < 26) curChar = (char)('a'+pos); else if(pos-26 < 26) curChar = (char)('A'+pos-26); else curChar = (char)('0'+pos-26-26); sb.append(curChar); } } return sb.toString(); }
這樣調用visualizableHash(hash(str))就能得到一個還能夠的文本哈希了cookie
其中payload就是我要負載的內容app
Sign做爲簽名校驗dom
目前是使用簡單的String,也提供了簡單的Map轉換
格式見doc說明測試
package com.noresp.oj.utils; import java.util.*; /** * 使用Token,解放Session * 注:一個Token的格式 * [encode(key1).encode(val1).encode(key2).encode(val2).....mySign] * 目前encode默認是base64 */ public class TokenUtils { private static String encode(String str) { if(str == null || str.length() == 0) return ""; return Base64.getEncoder().encodeToString(str.getBytes()); } private static String decode(String str) { if(str == null || str.length() == 0) return ""; return new String(Base64.getDecoder().decode(str.getBytes())); } public static String getTokenPayload(String key) { return encode(key); } public static String getTokenSign(String... base64Payloads) { StringBuilder sb = new StringBuilder(""); for(String payload : base64Payloads) { sb.append(StringUtils.visualizableHash(AlgsUtils.hash(payload))); } return sb.toString(); } public static String getToken(String... payloads) { StringBuilder sb = new StringBuilder(""); String[] encodedPayloads = new String[payloads.length]; for(int i = 0; i < payloads.length; i++) { encodedPayloads[i] = getTokenPayload(payloads[i]); sb.append(encodedPayloads[i]+"."); } sb.append(getTokenSign(encodedPayloads)); return sb.toString(); } /** * 解密和校驗Token * @param token * @return 若是校驗失敗,會返回null,不然返回Token解密內容 */ public static String[] decodeTokenAndValidate(String token) { if(token == null) return null; List<String> result = new LinkedList<>(); for(int i = 0, len = 1; i < token.length(); i++,len++) { if(token.charAt(i) == '.') { String payload = (token.substring(i-len+1,i)); result.add(payload); len = 0; } if(i == token.length()-1) { String salt = token.substring(i-len+1,i+1); len = 0; String[] encodedPayloads = new String[result.size()]; Iterator<String> itor = result.iterator(); while(itor.hasNext()) { encodedPayloads[len++] = itor.next(); } String comp = getTokenSign(encodedPayloads); if(!salt.equals(comp)) { return null; } String[] decodedPayloads = encodedPayloads; // 引用是同樣的 for(len = 0; len < encodedPayloads.length; len++) { decodedPayloads[len] = decode(encodedPayloads[len]); } return decodedPayloads; } } return null; } public static Map<String,String> tokenMap(String[] decodedToken) { Map<String,String> result = new HashMap<>(); if(decodedToken == null) return result; for(int i = 0; i < decodedToken.length; i+=2) { result.put(decodedToken[i],decodedToken[i+1]); } return result; } public static String getTokenAttribute(String token,String key) { Map<String,String> tokenMap = tokenMap(decodeTokenAndValidate(token)); return tokenMap.getOrDefault(key,null); } }
目前用於token的payload有userid和ip,後者是爲了進一步提升安全性優化
而且token是直接放在Cookie裏頭,方便管理生命週期
寫得比較雜亂,先貼部分感覺一下吧
@PostMapping("/register") public @ResponseBody String registerPost( HttpServletRequest request, HttpServletResponse response, @RequestParam(value = "email") String email, @RequestParam(value = "username") String username, @RequestParam(value = "password") String password) throws IOException { Boolean isCreated = userService.createUser(username,password,email,userService.getDefaultUserGroup()); Map<String,Boolean> result = new HashMap<>(); result.put("isCreated",isCreated); if(isCreated) { String token = TokenUtils.getToken( "userID", String.valueOf(userService.getUserByUsername(username).getUserID()), "ip",controllerUtils.getRemoteAddr(request) ); Cookie cookie = new Cookie("token",token); cookie.setMaxAge(60*60*24*7); cookie.setHttpOnly(true); response.addCookie(cookie); } return JSONUtils.toJSON(result); }
其中getRemoteAddr的實現爲
public String getRemoteAddr(HttpServletRequest request) { if ( request.getHeader("X-Real-IP") != null ) { return request.getHeader("X-Real-IP"); } return request.getRemoteAddr(); }
校驗過程太繁瑣了,固然要用到AOP,這裏採用註解的方式來實現
1.先給一個註解標記
@Retention(RetentionPolicy.RUNTIME) @Target(ElementType.METHOD) public @interface NeedLogin { }
2.接着就是AOP
(請無視直接println
@Component @Aspect public class LoginAspect { @Autowired ControllerUtils controllerUtils; /** * 【約定大於配置】 * 當須要使用@NeedLogin時,token需做爲入參的第一個保證AOP成功攔截 * Token校驗包括了加鹽的檢驗和IP的對比,以及開啓HttpOnly安全設置 * 若是有錯會及時把劫持的Cookie刪除 // PS.有點小瑕疵 * @param proceedingJoinPoint * @param token * @return * @throws Throwable */ @Around(value = "@annotation(com.noresp.oj.annotations.NeedLogin) && args(token,request,response,..)") public ModelAndView loginCheck( ProceedingJoinPoint proceedingJoinPoint, String token, HttpServletRequest request, HttpServletResponse response) throws Throwable { if(token == null) { System.out.println("沒有token"); return ViewUtils.redirect( "/",new ErrorInfo("login required")); } Map<String,String> tokenMap = TokenUtils.tokenMap( TokenUtils.decodeTokenAndValidate(token)); Integer userID = StringUtils.safeStringToInteger(tokenMap.get("userID")); String recordedIP = tokenMap.get("ip"); System.out.println(userID+" "+recordedIP); boolean tokenIllegal = userID == null || !controllerUtils.getRemoteAddr(request).equals(recordedIP); if(tokenIllegal) { System.out.println("token錯誤"); Cookie fakeToken = controllerUtils.getCookie(request,"token"); if(fakeToken != null) { fakeToken.setMaxAge(0); } return ViewUtils.redirect( "/",new ErrorInfo("login required")); } return (ModelAndView)proceedingJoinPoint.proceed(); } }
3.使用樣例
須要注意AOP沒有很好的arg通配方法,這裏使用的規約見上面定義
@NeedLogin @GetMapping("/{problemID}/submit") public ModelAndView submitView( @CookieValue(value = "token",required = false) String token, HttpServletRequest request, HttpServletResponse response, @PathVariable("problemID") int problemID) { ModelAndView view = new ModelAndView("/problems/submit"); Problem problem = problemService.getProblem(problemID); if(problem == null) { return ViewUtils.redirect("/",new ErrorInfo("No Such Problem.")); } view.addObject("problem",problem); return view; }
目前的不足 1.token長度受限於Cookie 2.校驗的複雜度仍是大了點