手動設計簡單的Token驗證

簡述一下項目中手寫的Token驗證服務設計過程java

PART A 設計校驗的哈希算法

這裏直接展現整個項目中用到的算法庫,其中涉及位運算的可無論
直接應用到的方法是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 B 進一步的哈希

由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

PART C util方法封裝

其中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);
    }
}

PART D 應用於WEB

目前用於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();
    }

PART E 更方便的使用

校驗過程太繁瑣了,固然要用到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.校驗的複雜度仍是大了點

相關文章
相關標籤/搜索