LZ第一次给app写开放接口,把自己处理Token的实现记录下来,目的是如果以后遇到好的实现,能在此基础上改进。这一版写法非常粗糙,写出来就是让大家批评的,多多指教,感谢大家。
当初设计这块想达到的效果或者说考虑到的问题有这么几点:
- 无状态 就是不要像后台管理系统那样用session维护,因为在分布式系统中存在一个session共享的问题,但是很可惜没有做到,目前使用redis维护的token。后面是否能考虑下用jjwt做。
- 用户一旦登录,除非用户点击退出登录,将一直保持登录状态,这个简单,redis不设置失效时间即可。但是这样做不好,应该考虑token的以旧换新,类似于微信的公众号开发。
- 如何确保每个登录用户的标识是唯一的,我用的是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分钟的,不允许重复访问。
这样做好处是:
- 因为token的存在和签名算法的不公开,确保接口安全。
- 如果参数泄露,攻击者也不能不间断的访问接口,5分钟后必须重新获得参数。(好像意义不大)
缺点:
- 因为生成signature的时候没有把参数加进去,所以一旦参数泄露,用户可以修改参数访问接口。
- 当前项目没有用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,这里有非常详细的介绍。可以帮助以上思路的实现。