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