关于Token (令牌)
说起令牌,仿佛回到了封建时代,一掏出令牌,其他人都知道你是什么身份了。
而在web系统中,前后端的业务交互也往往需要附带身份数据,先姑且把这个数据统称为 Token (令牌) 。
面向有状态HTTP请求:
Token实际上是由cookies+session 实现
服务端session存储了每个客户端的状态(比如用户登录后,服务端为这个客户端存储的session中就有了用户标识数据)。
session存储时为每一个客户端分配了sessionId
前端cookies存储了sessionId,保证同一个客户端一段时间内请求后端,使用同一个sessionId
以上实现了后端在一定时间内都"认识“这个客户端
面向无状态HTTP请求:
服务端不再存储每个请求客户端的状态,客户端也不再依赖cookies机制
客户端每次请求中自带身份标识
服务端根据客户端请求中的token参数来验证或者提取用户数据
可以有很多种实现,系统中定义一个token,或者使用JWT等
微服务架构下token授权与验证流程
我画了个微服务架构下tokenn授权与验证的流程。
其中我这里拟支持3种Token形式:
1、MD5 Token : 属于系统内部自定义一种Token规则,我这里只是简单地将用户的几个字段进行了拼接MD5,用户调用接口登录成功后,就把token生成并颁发给客户端。
验证时需要在数据库中查询token颁发记录,从而获取对应的用户信息
2、Jwt : JSON Web Tokens,一种便于分布式架构中传输Token的一种规范,跟上述的Token串不同的是,它可以直接把用户信息也打包进去。所以这里面常常存的不只是一个令牌,而是直接包含了业务接口所需的用户信息、以及业务数据。Jwt默认不加密,只是进行签名验证,防止篡改,但是里面存放的数据是可见的(通过Base64URL 解码)。
验证时直接从Jwt中提取登录用户信息,不需要查询数据库
3、Jwt+ AES : 是基于第二种方案进行了AES对称加密,这样在传输过程中,里面的数据是不可见的。
验证时进行AES解密出原Jwt,直接从Jwt中提取登录用户信息,不需要查询数据库
主要代码
完整代码:https://gitee.com/zhaojunfu2014/springcloud-learn-tokens
项目基于:springboot,mybatis plus
数据库:mysql
目的:实现 流程图的 2,3 6,7 节点 ,接口定义如下:
package com.jfcloud.all.user.service;
import com.jfcloud.all.user.domain.param.GrantParams;
/**
* 微服务鉴权服务
* @author zhaojunfu
*
*/
public interface TokenService {
/**
* 认证接口<br>
* 认证通过后返回token对象
* @param params 参数列表
* @return
* @throws Exception
*/
Object grant(GrantParams params) throws Exception;
/**
* 验证token有效性,并提取用户信息
* @param token
* @return
*/
Object validateAndGet(Object token);
}
自定义MD5 Token 实现
数据库表设计
jfcloud_user:用户表,模拟业务系统中需要登录的用户,登录后可用token验证后换取此用户信息
jfcloud_user_token: token表,为用户颁发token的记录
CREATE TABLE `jfcloud_user` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键',
`username` varchar(255) COLLATE utf8mb4_bin DEFAULT NULL COMMENT '用户名',
`realname` varchar(100) COLLATE utf8mb4_bin DEFAULT NULL COMMENT '昵称',
`password` varchar(255) COLLATE utf8mb4_bin DEFAULT NULL COMMENT '密码',
`account` decimal(20,2) DEFAULT NULL COMMENT '账户余额',
`order_num` bigint(20) DEFAULT NULL COMMENT '下单数量',
`buy_num` bigint(20) DEFAULT NULL COMMENT '购买产品数',
`buy_amount` decimal(20,2) DEFAULT NULL COMMENT '购买金额',
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
`is_delete` int(1) DEFAULT '0' COMMENT '逻辑删除',
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
CREATE TABLE `jfcloud_user_token` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`user_id` bigint(20) DEFAULT NULL COMMENT '用户ID',
`token` varchar(200) COLLATE utf8mb4_bin DEFAULT NULL COMMENT '用户登录token',
`status` int(1) DEFAULT '1' COMMENT '状态 0:无效 1:有效',
`create_time` datetime DEFAULT NULL,
`update_time` datetime DEFAULT NULL,
`is_delete` int(1) DEFAULT '0',
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=5 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
接口实现代码
package com.jfcloud.all.user.service.impl;
import java.time.LocalDateTime;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.Assert;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.jfcloud.all.user.domain.JfcloudUser;
import com.jfcloud.all.user.domain.JfcloudUserToken;
import com.jfcloud.all.user.domain.param.GrantParams;
import com.jfcloud.all.user.persistent.JfcloudUserService;
import com.jfcloud.all.user.persistent.JfcloudUserTokenService;
import com.jfcloud.all.user.service.TokenService;
import com.jfcloud.common.exception.BussinessException;
import com.jfcloud.common.utils.MD5;
/**
* 简易token授权模式<br>
* 认证通过后颁发token,token内容为:指定字段组拼接后的MD5<br>
* 被授权者后续通过该token调用 信息获取 接口获取所需数据
* @author zhaojunfu
*
*/
@Service(value = "SimpleTokenServiceImpl")
@Transactional
public class SimpleTokenServiceImpl implements TokenService {
private static final Logger LOGGER = LoggerFactory.getLogger(SimpleTokenServiceImpl.class);
@Autowired
private JfcloudUserService jfcloudUserService;
@Autowired
private JfcloudUserTokenService jfcloudUserTokenService;
@Override
public Object grant(GrantParams params) {
//1.参数校验
String username = params.getUsername();
String password = params.getPassword();
Assert.notNull(username, "用户名不能为空");
Assert.notNull(password, "密码不能为空");
//2.认证
JfcloudUser user = null;
try{
user=jfcloudUserService.getOne(new QueryWrapper<JfcloudUser>().eq("username", username) );
}catch (Exception e) {
LOGGER.error(e.getMessage(),e);
throw new BussinessException("账号异常");
}
Assert.notNull(user, "账号不存在");
String dbPwd = user.getPassword();
if(password.equals(dbPwd)) {
//验证通过
//3.判断是否已有token
JfcloudUserToken oldToken = jfcloudUserTokenService.getOne(
new QueryWrapper<JfcloudUserToken>().eq("user_id", user.getId())
.eq("status", 1)
.eq("is_delete", 0));
if(oldToken!=null) return oldToken.getToken();
//4.颁发token
JfcloudUserToken token = new JfcloudUserToken();
token.setUserId(user.getId());
token.setCreateTime(LocalDateTime.now());
token.setStatus(1);
//可指定任意数量的字段进行拼接MD5
String md5Token = MD5.generate(user.getId(),user.getUsername(),user.getPassword(),token.getCreateTime());
token.setToken(md5Token);
token.setIsDelete(0);
jfcloudUserTokenService.save(token);
return md5Token;
}else {
//验证不通过
throw new BussinessException("密码不正确");
}
}
@Override
public Object validateAndGet(Object token) {
JfcloudUserToken oldToken = jfcloudUserTokenService.getOne(
new QueryWrapper<JfcloudUserToken>().eq("token", token)
.eq("status", 1)
.eq("is_delete", 0));
Assert.notNull(oldToken, "token已失效");
JfcloudUser user = jfcloudUserService.getById(oldToken.getUserId());
Assert.isTrue(user.getIsDelete().equals(0), "用户已被删除");
return user;
}
}
控制器代码
/**
* 简单token模式:step1.授权
* @param params
* @return
*/
@PostMapping(path = "/simple/grant")
@ResponseBody
public RespData grant(@RequestBody GrantParams params) {
try {
Object token = simpleTokenService.grant(params);
return new RespData(token);
}catch (Exception e) {
LOGGER.error(e.getMessage(),e);
return new RespData(e);
}
}
/**
* 简单token模式:step2.鉴定token有效性,并返回认证数据
* @param token
* @return
*/
@GetMapping(path = "/simple/validateAndGet")
@ResponseBody
public RespData validateAndGet(@RequestParam String token) {
try {
Object obj = simpleTokenService.validateAndGet(token);
return new RespData(obj);
}catch (Exception e) {
LOGGER.error(e.getMessage(),e);
return new RespData(e);
}
}
效果验证
用户表数据:
步骤1:调用授权接口,提交用户名密码
颁发token
步骤2:调用验证接口去验证token,并且提取用户信息
此方案会根据token再去查一遍数据库
错误情况下:(此时前端应该废弃使用该token,并重新进行登录授权)
Jwt 方式实现
数据库表设计
为了便于区分,使用了两套表来承载不同的token方式。
jfcloud_user_jwt :记录颁发过的jwt
CREATE TABLE `jfcloud_user_jwt` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`user_id` bigint(20) DEFAULT NULL COMMENT '用户ID',
`jwt_token` text COLLATE utf8mb4_bin COMMENT 'jwt 信息',
`status` int(1) DEFAULT '1' COMMENT '状态 0:无效 1:有效',
`create_time` datetime DEFAULT NULL,
`update_time` datetime DEFAULT NULL,
`is_delete` int(1) DEFAULT '0',
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
接口实现代码
package com.jfcloud.all.user.service.impl;
import java.time.LocalDateTime;
import java.util.Map;
import org.apache.commons.beanutils.BeanUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.Assert;
import com.auth0.jwt.JWT;
import com.auth0.jwt.JWTVerifier;
import com.auth0.jwt.algorithms.Algorithm;
import com.auth0.jwt.exceptions.JWTVerificationException;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.jfcloud.all.user.domain.JfcloudUser;
import com.jfcloud.all.user.domain.JfcloudUserJwt;
import com.jfcloud.all.user.domain.param.GrantParams;
import com.jfcloud.all.user.persistent.JfcloudUserJwtService;
import com.jfcloud.all.user.persistent.JfcloudUserService;
import com.jfcloud.all.user.service.TokenService;
import com.jfcloud.common.exception.BussinessException;
/**
* jwt 非加密模式
* @author zhaojunfu
*
*/
@Service(value = "JwtTokenServiceImpl")
@Transactional
public class JwtTokenServiceImpl implements TokenService {
private static final Logger LOGGER = LoggerFactory.getLogger(SimpleTokenServiceImpl.class);
/*
* jwt 签名密钥
* 可在本地配置文件或者配置中心进行配置
*/
@Value("${jfcloud.jwt.sign:zjf2020}")
private String sign;
@Autowired
private JfcloudUserService jfcloudUserService;
@Autowired
private JfcloudUserJwtService jfcloudUserJwtService;
@Override
public Object grant(GrantParams params) throws Exception {
// 1.参数校验
String username = params.getUsername();
String password = params.getPassword();
Assert.notNull(username, "用户名不能为空");
Assert.notNull(password, "密码不能为空");
// 2.认证
JfcloudUser user = null;
try {
user = jfcloudUserService.getOne(new QueryWrapper<JfcloudUser>().eq("username", username));
} catch (Exception e) {
LOGGER.error(e.getMessage(), e);
throw new BussinessException("账号异常");
}
Assert.notNull(user, "账号不存在");
String dbPwd = user.getPassword();
if (password.equals(dbPwd)) {
// 验证通过
String token = "";
user.setPassword("");
token = JWT.create().withClaim("userInfo",fillNull( BeanUtils.describe(user) ))
.sign(Algorithm.HMAC256(sign));
JfcloudUserJwt jwt = new JfcloudUserJwt();
jwt.setUserId(user.getId());
jwt.setCreateTime(LocalDateTime.now());
jwt.setStatus(1);
jwt.setJwtToken(token);
jfcloudUserJwtService.save(jwt);
return token;
} else {
// 验证不通过
throw new BussinessException("密码不正确");
}
}
private Map<String,String> fillNull(Map<String, String> describe) {
for(String k :describe.keySet()) {
String v = describe.get(k);
if(v==null) {
describe.put(k, "");
}
}
return describe;
}
@Override
public Object validateAndGet(Object token) {
JWTVerifier jwtVerifier = JWT.require(Algorithm.HMAC256(sign)).build();
try {
jwtVerifier.verify(String.valueOf(token));
Map<String,Object> userMap = JWT.decode(String.valueOf(token)).getClaim("userInfo").asMap();
return userMap;
} catch (JWTVerificationException e) {
LOGGER.info("jwt 验证失败 "+e.getMessage());
throw e;
}
}
}
控制器代码
/**
* jwt token模式: step1.授权
* @param params
* @return
*/
@PostMapping(path = "/jwt/grant")
@ResponseBody
public RespData jwtGrant(@RequestBody GrantParams params) {
try {
Object token = jwtTokenService.grant(params);
return new RespData(token);
}catch (Exception e) {
LOGGER.error(e.getMessage(),e);
return new RespData(e);
}
}
/**
* jwt token模式:step2.鉴定token有效性,并返回认证数据
* @param token
* @return
*/
@RequestMapping(path = "/jwt/validateAndGet")
@ResponseBody
public RespData jwtValidateAndGet(@RequestParam(required = false) String token,HttpServletRequest request) {
try {
//支持以 POST GET URL参数方式校验token
//【优先】支持将 token放入http的header里(参数名为:jwt_token)
String headerToken = request.getHeader("jwt_token");
if(StringUtils.isNotEmpty(headerToken)) {
token = headerToken;
}
Assert.notNull(token,"token不能为空");
Object obj = jwtTokenService.validateAndGet(token);
return new RespData(obj);
}catch (Exception e) {
LOGGER.error(e.getMessage(),e);
return new RespData(e);
}
}
效果验证
步骤1:发送用户名密码,调用授权接口
jwt 中 直接包含了登录用户信息,通过base64URL解码可以查看内容
步骤2:调用验证接口去验证token,并且提取用户信息
因为比较长,所以最好是放入header中
此方案直接从Jwt中提取登录用户信息,不需要查询数据库
Jwt + AES 加密方式Token
接口实现比较简单,直接调用Jwt模式的接口,只是在调用前进行加密和解码,并且配置一个对称密钥: aesKey。
这个配置后续接入 springcloud后,可以直接放入nacos等配置中心
接口实现代码
package com.jfcloud.all.user.service.impl;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import com.jfcloud.all.user.domain.param.GrantParams;
import com.jfcloud.all.user.service.TokenService;
import com.jfcloud.common.utils.AESUtil;
/**
* jwt + aes 加密
* @author zhaojunfu
*
*/
@Service(value = "JwtAesServiceImpl")
@Transactional
public class JwtAesServiceImpl implements TokenService {
private static final Logger LOGGER = LoggerFactory.getLogger(JwtAesServiceImpl.class);
@Value(value = "${jfcloud.jwt.aes:zjf20200310}")
private String aesKey;
@Autowired
@Qualifier(value = "JwtTokenServiceImpl")
private TokenService jwtTokenService;
@Override
public Object grant(GrantParams params) throws Exception {
Object jwt = jwtTokenService.grant(params);
//加密后返回
String jwtAES=AESUtil.encrypt(String.valueOf(jwt), aesKey);
return jwtAES;
}
@Override
public Object validateAndGet(Object token) {
//先解密
String tokenDecrypted = AESUtil.decrypt(String.valueOf(token), aesKey);
return jwtTokenService.validateAndGet(tokenDecrypted);
}
}
控制器代码
/**
* jwt aes模式: step1.授权
* @param params
* @return
*/
@PostMapping(path = "/sjwt/grant")
@ResponseBody
public RespData sjwtGrant(@RequestBody GrantParams params) {
try {
Object token = jwtAesService.grant(params);
return new RespData(token);
}catch (Exception e) {
LOGGER.error(e.getMessage(),e);
return new RespData(e);
}
}
/**
* jwt aes模式:step2.鉴定token有效性,并返回认证数据
* @param token
* @return
*/
@RequestMapping(path = "/sjwt/validateAndGet")
@ResponseBody
public RespData sjwtValidateAndGet(@RequestParam(required = false) String token,HttpServletRequest request) {
try {
//支持以 POST GET URL参数方式校验token
//【优先】支持将 token放入http的header里(参数名为:jwt_token)
String headerToken = request.getHeader("jwt_token");
if(StringUtils.isNotEmpty(headerToken)) {
token = headerToken;
}
Assert.notNull(token,"token不能为空");
Object obj = jwtAesService.validateAndGet(token);
return new RespData(obj);
}catch (Exception e) {
LOGGER.error(e.getMessage(),e);
return new RespData(e);
}
}
效果验证
步骤1:发送用户名密码,调用授权接口
颁发的jwt 已经被AES加密过了
步骤2:调用验证接口去验证token,并且提取用户信息
此方案直接从Jwt中提取登录用户信息,不需要查询数据库
优化点:减少外部调用
其中,我们可以将 6,7 两个步骤省略掉,
采用JWT 以及 JWT+AES的情况下,可以直接把Token服务工程作为工程依赖,加入到要使用token的其他工程中。
在接收到前端的token后,直接在本地做验证即可。