码迷,mamicode.com
首页 > 其他好文 > 详细

Token以及签名signature的设计与实现

时间:2018-02-26 13:37:30      阅读:976      评论:0      收藏:0      [点我收藏+]

标签:返回   oid   []   后台管理   .json   仿微信   login   _id   还需   

  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都非常感谢。

 

Token以及签名signature的设计与实现

标签:返回   oid   []   后台管理   .json   仿微信   login   _id   还需   

原文地址:https://www.cnblogs.com/peterxiao/p/8472460.html

(0)
(0)
   
举报
评论 一句话评论(0
登录后才能评论!
© 2014 mamicode.com 版权所有  联系我们:gaon5@hotmail.com
迷上了代码!