Token以及簽名signature的設計與實現


  LZ第一次給app寫開放接口,把自己處理Token的實現記錄下來,目的是如果以后遇到好的實現,能在此基礎上改進。這一版寫法非常粗糙,寫出來就是讓大家批評的,多多指教,感謝大家。

  當初設計這塊想達到的效果或者說考慮到的問題有這么幾點:

  1. 無狀態 就是不要像后台管理系統那樣用session維護,因為在分布式系統中存在一個session共享的問題,但是很可惜沒有做到,目前使用redis維護的token。后面是否能考慮下用jjwt做。
  2. 用戶一旦登錄,除非用戶點擊退出登錄,將一直保持登錄狀態,這個簡單,redis不設置失效時間即可。但是這樣做不好,應該考慮token的以舊換新,類似於微信的公眾號開發。
  3. 如何確保每個登錄用戶的標識是唯一的,我用的是userId(登錄用戶的id,mysql中用的是自增序列)+uuid(如果只用uuid不合適,uuid也可能重復)。

  好,基於這3點,我們來看代碼實現(LZ的開發環境用的是是spring boot+mybatis+redis,如果對開發環境陌生可以參考LZ之前的博客spring boot+mybatis整合)。

  首先是token的模型:

/**
 * Token的Model類,可以增加字段提高安全性,例如時間戳、url簽名
 * @author xiaodong
 */
public class TokenModel {

    //用戶id
    private String userId;

    //accessToken
    private String accessToken;

    public TokenModel(String userId, String accessToken) {
        this.userId = userId;
        this.accessToken = accessToken;
    }

    public String getUserId() {
        return userId;
    }

    public void setUserId(String userId) {
        this.userId = userId;
    }

    public String getAccessToken() {
        return accessToken;
    }

    public void setAccessToken(String accessToken) {
        this.accessToken = accessToken;
    }
}

  TokenModel 在redis中存儲的時候是以accessToken為鍵,userId為值存儲的。因為accessToken是唯一的,所以不用擔心鍵沖突的問題。再有就是為什么叫accessToken,是模仿微信開發者平台上的命名,在生成signature的時候,api和app都維護了一個事先約定好的token,這個token不走網絡傳輸,增加了安全性,叫accessToken也是為了和這個token區分開。不理解沒關系,往下看。

  再來就是TokenModel的管理類:

/**
 * 對Token進行操作的接口
 * @author xiaodong
 */
public interface TokenManager {

    /**
     * 創建一個token關聯上指定用戶
     * @param userId 指定用戶的id
     * @return 生成的token
     */
    TokenModel createToken(String userId);

    /**
     * 檢查token是否有效
     * @param model token
     * @return 是否有效
     */
    boolean checkToken(TokenModel model);


    /**
     * 清除token
     */
    void deleteToken(String accessToken);

}

  該接口實現:

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;

import java.util.UUID;

/**
 * 通過Redis存儲和驗證token的實現類
 * @author xiaodong
 */
@Component
public class RedisTokenManager implements TokenManager {

    @Autowired
    private RedisTemplate<String, String> redisTemplate;


    public void setRedis(RedisTemplate redisTemplate) {
        this.redisTemplate = redisTemplate;
    }

    public TokenModel createToken(String userId) {
        //使用uuid作為源token
        String token = "accessToken:user"+userId+"-"+UUID.randomUUID().toString();
        TokenModel model = new TokenModel(userId, token);
        //存儲到redis並設置過期時間
        redisTemplate.boundValueOps(token).set(userId);
        return model;
    }

    public boolean checkToken(TokenModel model) {
        if (model == null ||model.getUserId() == null || model.getAccessToken() == null ) {
            return false;
        }
        String userId = redisTemplate.boundValueOps(model.getAccessToken()).get();
        if (!model.getUserId().equals(userId)) {
            return false;
        }
        return true;
    }

    public void deleteToken(String accessToken) {
        redisTemplate.delete(accessToken);
    }

}

   非常簡單,然后我們看用戶登錄的Controller。

import org.springframework.data.redis.core.RedisTemplate;

@RestController
@Api("登錄")
public class LoginController {


    //用戶操作類 具體實現不寫了,無非是用手機號碼查找用戶,基本操作
    @Autowired
    private UserService userService;

    //token管理類
    @Autowired
    private TokenManager tokenManager;

    //redis操作類
    @Autowired
    private RedisTemplate<String,String> redisTemplate;

   

    /**
     * 手機號碼+驗證碼登錄
     */
    @RequestMapping(value="login",method = RequestMethod.POST)
    public ResultModel login(@RequestBody LoginParam loginParam) {
        //從數據庫用手機號查到user,驗證碼校驗通過 即視為登錄成功
        ...

        //登錄成功后,生成token,將token返回給app
        TokenModel tokenModel = tokenManager.createToken(String.valueOf(user.getId()));
       
        ResultModel resultModel = new ResultModel(ResultStatusCode.OK,tokenModel);
        return resultModel;
    }
    
    /**
     * 退出登錄
     */
    @RequestMapping(value="logout",method = RequestMethod.POST)
    @Authorization
    public ResultModel logout(@RequestHeader(Constants.ACCESS_TOKEN) String  accessToken) {
        tokenManager.deleteToken(accessToken);
        return new ResultModel(ResultStatusCode.OK);
    }
}
  其中redisTemplate是spring boot提供的默認實現,可直接用,@Authorization是自定義的注解,凡是需要登錄攔截的接口都加這個注解。我們看一下這個注解和自定義的攔截器是如何實現的。
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

/**
 * 在Controller的方法上使用此注解,該方法在映射時會檢查用戶是否登錄,未登錄返回401錯誤
 * @author xiaodong
 */
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Authorization {
}

  注解很簡單,接下來是自定義攔截器。

import com.alibaba.fastjson.JSON;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.handler.HandlerInterceptorAdapter;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.PrintWriter;
import java.lang.reflect.Method;

/**
 * 自定義攔截器,判斷此次請求是否有權限
 * @author xiaodong
 */
@Component
public class AuthorizationInterceptor extends HandlerInterceptorAdapter {

    private static final Logger logger = LoggerFactory.getLogger(AuthorizationInterceptor.class);

    @Autowired
    private TokenManager manager;

    public boolean preHandle(HttpServletRequest request,
                             HttpServletResponse response, Object handler) throws Exception {

        logger.info("請求IP:"+ IpUtil.getIp(request));

        //如果不是映射到方法直接通過
        if (!(handler instanceof HandlerMethod)) {
            return true;
        }
        HandlerMethod handlerMethod = (HandlerMethod) handler;
        Method method = handlerMethod.getMethod();

        //沒有@Authorization注解直接通過
        if (method.getAnnotation(Authorization.class) == null) {
            return true;
        }

        /***sign認證簽名 begin***/
        //接受參數 微信加密簽名 時間戳 隨機數
        String signature = request.getHeader(Constants.SIGNATURE);
        String timestamp = request.getHeader(Constants.TIMESTAMP);
        String nonce = request.getHeader(Constants.NONCE);
        //比較時間戳
        long nowTimeStamp = System.currentTimeMillis();
        long appTimeStamp = 0 ;
        if(timestamp != null){
            appTimeStamp = Long.valueOf(timestamp);
        }
        //url請求過期(5分鍾) swagger暫時沒有每次都改變這3個參數 待優化 TODO
        if(nowTimeStamp - appTimeStamp >1000*60*5 ){
            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
            return false;
        }
        //請求校驗
        if (!SignUtil.checkSignature(signature, timestamp, nonce)) {
            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
            returnErrorMessage(response,"用戶無權訪問該接口");
            return false;
        }
        /***sign認證簽名 end***/

        //從請求頭中獲得accessToken
        String accessToken = request.getHeader(Constants.ACCESS_TOKEN);
        //從請求頭中獲得userid
        String userId = request.getHeader(Constants.CURRENT_USER_ID);
        TokenModel model = new TokenModel(userId,accessToken);

        if (manager.checkToken(model)) {
            return true;
        }
        //如果驗證token失敗,並且方法注明了Authorization,返回401錯誤
        if (method.getAnnotation(Authorization.class) != null) {
            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
            returnErrorMessage(response,"用戶無權訪問該接口");
            return false;
        }
        return true;
    }

    private void returnErrorMessage(HttpServletResponse response, String errorMessage) throws IOException {
        ResultModel rst = new ResultModel("401",errorMessage);
        response.setContentType("application/json");
        response.setCharacterEncoding("UTF-8");
        PrintWriter out = response.getWriter();
        out.print(JSON.toJSONString(rst));
        out.flush();
    }

}

  自定義的攔截器需要注冊。

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurerAdapter;


/**
 * 配置類,增加自定義攔截器
 * @author xiaodong
 */
@Configuration
public class MvcConfig extends WebMvcConfigurerAdapter {

    @Autowired
    private AuthorizationInterceptor authorizationInterceptor;

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        InterceptorRegistration addInterceptor = registry.addInterceptor(authorizationInterceptor);
        // 排除配置
        addInterceptor.excludePathPatterns("/login**");
        // 攔截配置
        addInterceptor.addPathPatterns("/**");
        super.addInterceptors(registry);
    }

}

  登錄成功以后,app收到accessToken和userId會以公共參數的形式放到request header中,這樣用自定義的攔截器每次去header中拿就可以了,如果是我系統的用戶,就通過,如果校驗不通過,就返回401。這里為了增加安全性,我借鑒了微信公眾號開發的簽名算法,貼出來。

import org.apache.commons.lang3.RandomStringUtils;

import java.security.MessageDigest;
import java.util.Arrays;
import java.util.Date;

/**
 * 簽名校驗工具類
 * @author xiaodong
 *
 */
public class SignUtil {
    //校驗簽名的token 事先與app約定
    private static String token="...";

    /**
     * 校驗簽名
     * @param signature 微信加密簽名
     * @param timestamp 時間戳
     * @param nonce 隨機數
     * @return
     */
    public static boolean checkSignature(String signature,String timestamp,String nonce){
        if(signature==null || timestamp == null || nonce == null){
            return false;
        }
        //對token,timestamp nonce 按字典排序
        String[] paramArr=new String[]{token,timestamp,nonce};
        Arrays.sort(paramArr);

        //將排序后的結果拼接成一個字符串
        String content=paramArr[0].concat(paramArr[1]).concat(paramArr[2]);
        String ciphertext=null;

        try {
            MessageDigest md=MessageDigest.getInstance("SHA-1");
            //對拼接后的字符串進行sha1加密
            byte[] digest=md.digest(content.toString().getBytes());
            ciphertext=byteToStr(digest);
        } catch (Exception e) {
            // TODO: handle exception
        }

        //將sha1加密后的字符串與signature進行對比
        return ciphertext!=null?ciphertext.equals(signature.toUpperCase()):false;
    }

    /**
     * 生成簽名 android使用
     * @param timestamp 時間戳
     * @param nonce 隨機數
     * @return
     */
    public static String getSignature(String timestamp,String nonce){
        //對token,timestamp nonce 按字典排序
        String[] paramArr=new String[]{token,timestamp,nonce};
        Arrays.sort(paramArr);

        //將排序后的結果拼接成一個字符串
        String content=paramArr[0].concat(paramArr[1]).concat(paramArr[2]);
        String ciphertext=null;

        try {
            MessageDigest md=MessageDigest.getInstance("SHA-1");
            //對拼接后的字符串進行sha1加密 update// 使用指定的字節數組對摘要進行最后更新
            byte[] digest=md.digest(content.toString().getBytes());//完成摘要計算
            ciphertext=byteToStr(digest);
        } catch (Exception e) {
            e.printStackTrace();
        }
        //將sha1加密后的字符串與signature進行對比
        return ciphertext;
    }


    /**
     * 將字節數組轉換成十六進制字符串
     * @param byteArray
     * @return
     */
    private static String byteToStr(byte[] byteArray){
        String strDigest="";
        for (int i = 0; i < byteArray.length; i++) {
            strDigest+=byteToHexStr(byteArray[i]);
        }
        return strDigest;
    }

    private static String byteToHexStr(byte mByte){
        char[] Digit={'0','1','2','3','4','5','6','7','8','9','A','B','C','D','E','F'};
        char[] tempArr=new char[2];
        tempArr[0]=Digit[(mByte >>> 4) & 0X0F];
        tempArr[1]=Digit[mByte & 0X0F];

        String s=new String(tempArr);
        return s;
    }

    /**
     *  生成隨機數
     */
    public static String generateVerificationCode() {
        return RandomStringUtils.random(2, "123456789");
    }


    /**
     * 當前時間
     * 獲取精確到秒的時間戳
     * @return
     */
    public static String getSecondTimestamp(){
        String timestamp = String.valueOf(new Date().getTime()/1000);
        return timestamp;
    }

}

 其中token是和app事先約定好的,不走網絡,app訪問api開放接口的時候,除了帶上userid和accessToken以外,還要在本地按相同算法生成signature,然后連帶生成簽名時用到的timestamp(時間戳)和nonce(隨機數)放在request header中和userId、accessToken一起傳給我,我拿到timestamp和nonce后,用相同的算法計算出signature,然后和app給我的singnature對比,相同則可以訪問接口,不相同則返回401,同時,我還會對時間戳做限制,當前時間的時間戳減去app時間戳大於5分鍾的,不允許重復訪問。

這樣做好處是:

  1. 因為token的存在和簽名算法的不公開,確保接口安全。
  2. 如果參數泄露,攻擊者也不能不間斷的訪問接口,5分鍾后必須重新獲得參數。(好像意義不大)

缺點:

  1. 因為生成signature的時候沒有把參數加進去,所以一旦參數泄露,用戶可以修改參數訪問接口。
  2. 當前項目沒有用https請求,http請求不安全。

我把剩余的ResultModel類補上。

/**
 * 自定義返回結果
 * @author xiaodong
 */
@ApiModel(value = "ResultModel", description = "統一返回結果")
public class ResultModel{

    /**
     * 返回碼
     */
    @ApiModelProperty(value = "返回碼")
    private String code;

    /**
     * 返回結果描述
     */
    @ApiModelProperty(value = "返回結果描述")
    private String message;

    /**
     * 返回內容
     */
    @ApiModelProperty(value = "返回內容")
    private Object content;

    public void setCode(String code) {
        this.code = code;
    }

    public void setMessage(String message) {
        this.message = message;
    }

    public void setContent(Object content) {
        this.content = content;
    }

    public String getCode() {
        return code;
    }

    public String getMessage() {
        return message;
    }

    public Object getContent() {
        return content;
    }

    public ResultModel(){};
    public ResultModel(String code, String message) {
        this.code = code;
        this.message = message;
        this.content = "";
    }

    public ResultModel(String code, String message, Object content) {
        this.code = code;
        this.message = message;
        this.content = content;
    }

    public ResultModel(ResultStatusCode status) {
        this.code = status.getCode();
        this.message = status.getMessage();
        this.content = "";
    }

    public ResultModel(ResultStatusCode status, Object content) {
        this.code = status.getCode();
        this.message = status.getMessage();
        this.content = content;
    }

    public static ResultModel ok(Object content) {
        return new ResultModel(ResultStatusCode.OK, content);
    }

    public static ResultModel ok() {
        return new ResultModel(ResultStatusCode.OK);
    }

    public static ResultModel error(ResultStatusCode error) {
        return new ResultModel(error);
    }
}
/**
 * 返回碼
 * @author xiaodongdong
 **/
public enum ResultStatusCode {
    OK("0", "請求成功"),
    SYSTEM_ERR("30001", "系統錯誤");

    private String code;
    private String message;

    public String getCode() {
        return code;
    }

    public void setCode(String code) {
        this.code = code;
    }

    public String getMessage() {
        return message;
    }

    public void setMessage(String message) {
        this.message = message;
    }

    ResultStatusCode(String code, String message)
    {
        this.code = code;
        this.message = message;
    }
}

  整個登錄注冊的邏輯就是這樣的,因為第一次做api,我心里非常明白整個邏輯還需要大改,但是方向在哪里,看到的各路大神盡管批評,無論對錯,LZ都非常感謝。

 

=================2019.04.17追加=================

重新審視這個問題。

方案制定

  開始的時候,首先要制定方案,應用層的協議采用http,這點是確定的。對於加密,LZ想來想去,基本上有兩種選擇。

  第一種是傳統的辦法,使用自簽名證書,借用jdk和web容器的ssl層實現,這種方法比較常用,也比較省事。

  第二種是手動編程的方法,類似於自己寫了一層ssl的實現。原理也很簡單,對方把數據加密后傳給LZ的服務端,LZ這邊解密后該怎么處理就怎么處理,完事以后把響應的數據加密傳給客戶端,客戶端解密之后該怎么處理就怎么處理。

  經過一番實驗和思考,LZ還是決定采用第二種方法。主要原因是,這種方式更加靈活,加密方案是LZ可以隨意更改的(比如把其中的某個算法用別的算法替換)。還有一點原因是,自己寫的東西更加容易掌控,如果加密層出現問題,LZ作為PM可以更快的定位問題。最后一點原因是,基於算法而不是基於Java類庫,更容易制作各種語言的客戶端。

代碼設計

  方案基本確定,接下來就是代碼設計。代碼設計分為客戶端和服務端,作為客戶端,LZ可以提供公用的加密解密組件給合作伙伴調用(比如java客戶端,php客戶端,.NET客戶端等等)。作為服務端,LZ只需要過濾器和定制視圖就可以輕易完成加密和解密的工作。

  最終寫出來的客戶端API如下:

  HttpsHelper.sendJsonAndGetJson(JSONObject json);

  HttpsHelper.sendJsonAndGetJson(JSONObject json,int timeout);

  以上就是客戶端組件公布的兩個方法,方法的作用很好理解,LZ就不多說了。在方法的實現當中,LZ已經幫客戶端完成了加密和解密操作。當然,使用這個客戶端的前提是,得到LZ給予的授權碼。

  服務端需要一個過濾器和一個定制的json視圖。

  SecurityFilter

  JsonView

  由於LZ發布的是restful風格的服務,因此使用的mvc框架是spring mvc。這兩個類的具體代碼這里就不貼了,總之過濾器完成請求參數的解密,視圖完成響應結果的加密。

ssl層實現

  以上基本上已經完成了整個加密解密功能的設計,接下來的工作就是將工作落實到實處,到底加密算法如何選擇?

  之前LZ對加密解密算法可謂是大大的小白,就知道一個md5算法,一般是用於密碼加密的。這下可難倒LZ了,不過沒關系,有百度和google,還有什么不能在幾天之內學到的東西嗎。

  經過一番百度和google,LZ發現算法主要分為以下三種:

  1,不可逆加密算法,比如md5就是這樣一種,這種算法一般用於校驗,比如校驗用戶的密碼對不對。

  2,對稱加密算法,這種算法是可逆的,兩邊擁有同一個密鑰,使用這個密鑰可以對數據加密和解密,一般用於數據加密傳輸。特點是速度快,但安全性相對於非對稱加密較低。

  3,非對稱加密算法,這種算法依然是可逆的,兩邊擁有不同的密鑰,一個叫公鑰,一個叫私鑰,兩邊也都可以對數據加密和解密,一般用於數字簽名。特點是速度較慢,但安全性相對於對稱加密更高。

  之前LZ聽說過ssl的實現是幾種算法混合使用的,這給了LZ很大的啟示。既然每種算法都有它的優勢,我們為何不混合使用呢。

  於是,LZ想來想去(主要是在公車上以及廁所思考),決定使用md5(不可逆加密)+des(對稱加密)+rsa(非對稱加密)的加密方式,編碼格式可以使用Base64。來看看LZ的需求,主要有兩點。

  1,客戶端需要LZ授權,也就是說LZ發布的服務不是誰想調就能調的。

  2,數據在傳輸過程中是加密的,並且安全性要等同於非對稱加密算法的安全性,但性能要等同於對稱加密的速度。

  我們來看看以上的算法實現能否滿足需要,過程是這樣的。

  1,假設LZ給客戶端一個授權碼,比如123456。再假設客戶端現在需要傳的數據是{"name":"xiaolongzuo"}。(請求數據和響應數據都是json格式)

  2,客戶端需要先對123456進行md5加密,然后放入到傳輸數據中。也就是傳輸的數據會變成{"name":"xiaolongzuo","verifyCode":"md5(123456)"}

  3,客戶端生成des的隨機密鑰(注意,對稱密鑰每次都是隨機生成的),假設是abcdef,客戶端使用該密鑰對傳輸數據進行des加密,並且對隨機密鑰進行rsa加密,最終拼成一個json。也就是最終傳輸的數據會變成{"privateKey":"rsa(abcdef)","data":"des({"name":"xiaolongzuo","verifyCode":"md5(123456)"})"}

  4,服務端使用相反的過程對數據進行解密即可,並驗證解密后的授權碼md5(123456)是否存在,如果不存在,則認為該客戶端未被授權。當服務端返回數據時,依舊使用abcdef對數據進行des加密即可。

  安全性分析:假設以上的數據被黑客攔截,那么黑客最主要做的就是破解rsa算法的私鑰(私鑰只有LZ有,客戶端組件中會附帶公鑰),這個問題聽說是比較難的,具體為什么,這就不是LZ需要考慮的了,LZ還沒這個能力。基於這個前提,LZ可以認為傳輸的數據還是比較安全的。

  性能分析:由於我們的rsa只對長度比較短的des私鑰進行加密,因此非對稱加密速度慢的特點並不會影響我們太多。幾乎上所有的傳輸數據,我們都是使用的des進行加密,因此在速度上,幾乎等同於對稱加密的速度。

小結

  在沒有采用https協議情況下,可以采用以上方案,該方案是網上查到的,LZ覺得可以一試。另外,關於各個加密算法的實現,推薦博客:https://snowolf.iteye.com/category/68576,這里有非常詳細的介紹。可以幫助以上思路的實現。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM