【Jwt】详解认证登录的数字签名
【Jwt】详解认证登录的数字签名
- 【一】jwt概念
- 【1】什么是jwt
- 【2】jwt有什么用
- 【3】jwt的组成结构
- (1)Header:头信息
- (2)PayLoad:用户信息
- (3)Signature:签名
- 【4】JWT的优点和缺点
- (1)优点
- (2)缺点:
- 【二】使用jwt
- 【0】引入依赖
- 【1】生成token
- 【2】解析token
- 【3】验证逻辑
- 【4】完整工具类
- 【5】登录逻辑
- 【三】登录问题
- 【1】有无状态登录
- (1)有状态登录
- (2)无状态的登录
- 【2】分布式项目登录问题
- (1)解析token并向下游的微服务传递
- (2)完整的登录和鉴权过程
- (3)Token续签问题
- (1)单token方案
- (2)双token方案
- (3)方案
- (4)Token续期处理
- (5)Token登出处理
- 【4】如何获取客户端的各种信息
【一】jwt概念
【1】什么是jwt
Json Web Token是通过数字签名的方式,以json为载体,在不同的服务之间安全的传输信息的技术。
【2】jwt有什么用
一般使用在授权认证的过程中,一旦用户登录,后端返回一个token给前端,相当于后端给了前端返回了一个授权码,之后前端向后端发送的每一个请求都需要包含这个token,后端在执行方法前会校验这个token(安全校验),校验通过才执行具体的业务逻辑。
【3】jwt的组成结构
JWT由Header(头信息),PayLoad (用户信息),Signature(签名)三个部分组成
(1)Header:头信息
Header头信息主要声明加密算法:(具体算法对称不对称加密不作为研究内容)
通常直接使用 HMAC HS256这样的算法
{
"typ":"jwt"
"alg":"HS256" //加密算法
}
然后将头部进行base64加密(该加密是可以对称解密的),构成了第一部分
(2)PayLoad:用户信息
指定了七个默认字段供选择
iss:发行人
exp:到期时间
sub:主体
aud:用户
nbf:在此之前不可用
iat:发布时间
jti:JWT ID用于标识该JWT
也可以自定义私有字段,但是不建议在此存放密码之类的敏感信息,因为此部分可以解码还原出原始内容。虽然它可以解码,但是也不能修改这个内容。
{
"username":"zhangsan",
"name":"张三",
}
对其进行base64加密,得到Jwt的第二部分
eyJ1c2VybmFtZSI6InpoYW5nc2FuIiwibmFtZSI6IuW8oOS4iSIsImFnZSI6MTgsInNleCI6IuWlsyIsImV4cCI6MTY0NzE0NTA1MSwianRpIjoiMTIxMjEyMTIxMiJ9
(3)Signature:签名
此部分用于防止jwt内容被篡改。这个签证信息由三部分组成(由加密后的Header,加密后的PayLoad,加密后的签名三部分组成)
header (base64后的)
payload (base64后的)
secret
base64加密后的header和base64加密后的payload使用.连接组成的字符串,然后通过header中声明的加密方式进行加盐加密,然后就构成了jwt的第三部分,每个部分直接使用"."来进行拼接
eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VybmFtZSI6InpoYW5nc2FuIiwibmFtZSI6IuW8oOS4iSIsImFnZSI6MTgsInNleCI6IuWlsyIsImV4cCI6MTY0NzE0NTA1MSwianRpIjoiMTIxMjEyMTIxMiJ9.5tmHCpcsS_VuZ2_z5Rydf2OpsviBGwB-fJE5aS7gKqE
【4】JWT的优点和缺点
基于session和基于jwt的方式的主要区别就是用户的状态保存的位置,session是保存在服务端的,而jwt是保存在客户端的。
(1)优点
1-jwt基于json,非常方便解析。
2-可以在令牌中自定义丰富的内容,易扩展。
3-资源服务使用JWT可不依赖认证服务即可完成授权。
4-通过非对称加密算法及数字签名技术,JWT防止篡改,安全性高。
(2)缺点:
1-JWT 中的信息可以在客户端解码,因此敏感信息不应该存储在 JWT 中,尤其是不加密的情况下。
2-如果使用对称加密算法并且密钥被泄漏,攻击者可以使用该密钥签发有效的 JWT。为了防止这种情况发生,应该使用安全的加密算法,并妥善保管密钥。
3-JWT 不支持会话管理,也不能主动使令牌失效。因此,在某些情况下,需要实现其他机制来管理用户会话和授权状态。
4-JWT 一旦签发,就无法撤回或修改,除非到了过期时间。因此,如果令牌被盗用,攻击者可以使用它来获得未经授权的访问权限。
5-JWT 的长度相对较长,可能会影响网络传输性能。
【二】使用jwt
【0】引入依赖
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.1</version>
<scope>provided</scope>
</dependency>
【1】生成token
// 秘钥
private static final String SECRET_KEY = "123456654321"; // 建议存储于环境变量
// 过期时间
private static final long EXPIRATION_MS = 86400000; // 24小时
public String generateToken(UserDetails userDetails) {
Map<String, Object> claims = new HashMap<>();
return createToken(claims, userDetails.getUsername());
}
// 创建一个token,参数为claims和subject
private String createToken(Map<String, Object> claims, String subject) {
// 使用Jwts.builder()创建一个token
return Jwts.builder()
// 设置token的claims
.setClaims(claims)
// 设置token的主题
.setSubject(subject)
// 设置token的签发时间
.setIssuedAt(new Date(System.currentTimeMillis()))
// 设置token的过期时间
.setExpiration(new Date(System.currentTimeMillis() + EXPIRATION_MS * 1000))
// 使用HS256算法和SECRET_KEY对token进行签名
.signWith(SignatureAlgorithm.HS256, SECRET_KEY)
// 将token压缩成一个字符串
.compact();
}
【2】解析token
// 从token中提取所有声明
public Claims extractAllClaims(String token) {
// 使用SECRET_KEY解析token,并返回声明
Claims claims = null;
try {
claims = Jwts.parser().setSigningKey(SECRET_KEY).parseClaimsJws(token).getBody();
} catch (SignatureException e) {
log.info("无效的JWT签名:{}",e.getMessage());
throw new CustomRuntimeException("无效的JWT签名,请重新登录");
}
return claims;
}
【3】验证逻辑
// 验证token是否有效,参数为token和userDetails
public Boolean validateToken(String token, UserDetails userDetails) {
// 从token中提取用户名
final String username = extractUsername(token);
// 返回用户名是否与userDetails中的用户名相等,并且token是否未过期
return (username.equals(userDetails.getUsername()) && !isTokenExpired(token));
}
// 从token中提取用户名
public String extractUsername(String token) {
// 从token中提取声明,并返回用户名
return extractClaim(token, Claims::getSubject);
}
// 从token中提取指定类型的声明
public <T> T extractClaim(String token, Function<Claims, T> claimsResolver) {
// 从token中提取所有的claims
final Claims claims = extractAllClaims(token);
// 使用claimsResolver函数处理claims,并返回结果
return claimsResolver.apply(claims);
}
// 从token中提取所有声明
public Claims extractAllClaims(String token) {
// 使用SECRET_KEY解析token,并返回声明
Claims claims = null;
try {
claims = Jwts.parser().setSigningKey(SECRET_KEY).parseClaimsJws(token).getBody();
} catch (SignatureException e) {
log.info("无效的JWT签名:{}",e.getMessage());
throw new CustomRuntimeException("无效的JWT签名,请重新登录");
}
return claims;
}
【4】完整工具类
@Component
@Slf4j
public class JwtUtils {
// 秘钥
private static final String SECRET_KEY = "123456654321"; // 建议存储于环境变量
// 过期时间
private static final long EXPIRATION_MS = 86400000; // 24小时
public String generateToken(UserDetails userDetails) {
Map<String, Object> claims = new HashMap<>();
return createToken(claims, userDetails.getUsername());
}
// 创建一个token,参数为claims和subject
private String createToken(Map<String, Object> claims, String subject) {
// 使用Jwts.builder()创建一个token
return Jwts.builder()
// 设置token的claims
.setClaims(claims)
// 设置token的主题
.setSubject(subject)
// 设置token的签发时间
.setIssuedAt(new Date(System.currentTimeMillis()))
// 设置token的过期时间
.setExpiration(new Date(System.currentTimeMillis() + EXPIRATION_MS * 1000))
// 使用HS256算法和SECRET_KEY对token进行签名
.signWith(SignatureAlgorithm.HS256, SECRET_KEY)
// 将token压缩成一个字符串
.compact();
}
// 验证token是否有效,参数为token和userDetails
public Boolean validateToken(String token, UserDetails userDetails) {
// 从token中提取用户名
final String username = extractUsername(token);
// 返回用户名是否与userDetails中的用户名相等,并且token是否未过期
return (username.equals(userDetails.getUsername()) && !isTokenExpired(token));
}
// 从token中提取用户名
public String extractUsername(String token) {
// 从token中提取声明,并返回用户名
return extractClaim(token, Claims::getSubject);
}
// 从token中提取过期时间
public Date extractExpiration(String token) {
// 调用extractClaim方法,传入token和Claims::getExpiration方法引用
return extractClaim(token, Claims::getExpiration);
}
// 从token中提取指定类型的声明
public <T> T extractClaim(String token, Function<Claims, T> claimsResolver) {
// 从token中提取所有的claims
final Claims claims = extractAllClaims(token);
// 使用claimsResolver函数处理claims,并返回结果
return claimsResolver.apply(claims);
}
// 从token中提取所有声明
public Claims extractAllClaims(String token) {
// 使用SECRET_KEY解析token,并返回声明
Claims claims = null;
try {
claims = Jwts.parser().setSigningKey(SECRET_KEY).parseClaimsJws(token).getBody();
} catch (SignatureException e) {
log.info("无效的JWT签名:{}",e.getMessage());
throw new CustomRuntimeException("无效的JWT签名,请重新登录");
}
return claims;
}
// 判断token是否过期
public Boolean isTokenExpired(String token) {
// 提取token中的过期时间
return extractExpiration(token).before(new Date());
}
/**
* 根据令牌获取用户标识
*
* @param token 令牌
* @return 用户ID
*/
public String getUserKey(String token)
{
Claims claims = extractAllClaims(token);
return getValue(claims, SecurityConstants.USER_KEY);
}
/**
* 根据身份信息获取键值
*
* @param claims 身份信息
* @param key 键
* @return 值
*/
public String getValue(Claims claims, String key)
{
return Convert.toStr(claims.get(key), "");
}
/**
* 根据令牌获取用户标识
*
* @param claims 身份信息
* @return 用户ID
*/
public String getUserKey(Claims claims)
{
return getValue(claims, SecurityConstants.USER_KEY);
}
/**
* 根据身份信息获取用户ID
*
* @param claims 身份信息
* @return 用户ID
*/
public String getUserId(Claims claims)
{
return getValue(claims, SecurityConstants.DETAILS_USER_ID);
}
/**
* 根据令牌获取用户名
*
* @param token 令牌
* @return 用户名
*/
public String getUserName(String token)
{
Claims claims = extractAllClaims(token);
return getValue(claims, SecurityConstants.DETAILS_USERNAME);
}
/**
* 根据身份信息获取用户名
*
* @param claims 身份信息
* @return 用户名
*/
public String getUserName(Claims claims)
{
return getValue(claims, SecurityConstants.DETAILS_USERNAME);
}
}
【5】登录逻辑
jwt整合到springSecurity使用案例再做详细整理
【三】登录问题
【1】有无状态登录
(1)有状态登录
cookie+session,所谓的状态就是在服务端存储session信息。客户端访问服务端的时候,在cookie中携带sessionId,服务端根据sessionId就能才找到对应的session信息。
缺点:分布式支持不好,默认是内存存储,一般用的时候:把sessionId存到redis中
(2)无状态的登录
服务端不存储session信息,就需要token机制,客户端访问服务端的时候,需要在header或者是cookie中传递token信息,并且token中是携带了用户信息的。一旦生成,不能修改。
优点:分布式支持好
缺点:实现比较麻烦,续约、登出
续约:每次访问重新生成一个新的token(不推荐);使用两个token,accesstoken refreshtoken(有效期长); 临近过期生成新的token
登出: 登出之后,把token的id存入redis的黑名单
【2】分布式项目登录问题
redis存储session信息
用户登录成功以后,服务端生成一个uuid(token),同时把uuid作为key,用户信息作为value存储到redis中,然后把uuid返回给客户端。客户端在访问服务端接口的时候,可以在cookie或者header中传递这个uuid,服务端收到请求,首先读取这个uuid,然后根据uuid去redis中查找用户。这种方式也可以避免session的不同步问题,因为现在是存储到了多个节点都可以访问的redis中。
(1)解析token并向下游的微服务传递
以admin为例,admin中所有的接口(除了登录),都需要登陆以后才能访问。admin服务接口中需要知道是哪个用户的请求。所以网关直接把解析出来的userId传递给下游的微服务即可。
如何传递?
// 把userId传递给下游的微服务
request.mutate().header("userId", "" + userId);
使用的是spring5提供webflux的api向request中追加请求头。
(2)完整的登录和鉴权过程
(1)前端用户传递的用户名和密码,服务端收到请求,根据用户名到db查询记录,根据db中记录的password与前端传递的password做匹配,如果能匹配成功,则生成jwt token返回给前端,token中携带了userId。
(2)前端会把token保存到local storage(本地存储器)中,在随后的访问中,在header中携带上这个token。
(3)前端的请求首先是到网关,网关中有一个全局的过滤器,会从请求中读取header中的token,解析,把解析出来的userId放入http的header中继续向下游的微服务传递,header的key=userId。
(4)微服务中有一个拦截器,在拦截器中拦截请求,从header中解析出userId,然后保存到ThreadLocal中。
(5)在微服务的接口中,就可以使用ThreadLocal来获取用户信息。
(3)Token续签问题
(1)单token方案
(1)将 token 过期时间设置为15分钟;
(2)前端发起请求,后端验证 token 是否过期;如果过期,前端发起刷新token请求,后端为前端返回一个新的token;
(3)前端用新的token发起请求,请求成功;
(4)如果要实现每隔72小时,必须重新登录,后端需要记录每次用户的登录时间;用户每次请求时,检查用户最后一次登录日期,如超过72小时,则拒绝刷新token的请求,请求失败,跳转到登录页面。
另外后端还可以记录刷新token的次数,比如最多刷新50次,如果达到50次,则不再允许刷新,需要用户重新授权。
(2)双token方案
(1)登录成功以后,后端返回 access_token 和 refresh_token,客户端缓存此两种token;
(2)使用 access_token 请求接口资源,成功则调用成功;如果token超时,客户端携带 refresh_token 调用token刷新接口获取新的 access_token;
(3)后端接受刷新token的请求后,检查 refresh_token 是否过期。如果过期,拒绝刷新,客户端收到该状态后,跳转到登录页;如果未过期,生成新的 access_token 返回给客户端。
(4)客户端携带新的 access_token 重新调用上面的资源接口。
(5)客户端退出登录或修改密码后,注销旧的token,使 access_token 和 refresh_token 失效,同时清空客户端的 access_token 和 refresh_toke。
(3)方案
(1)登录成功,生成两个token存到cookie或者local storage (一长一短)
(2)发起请求,网关检验token(短),验证通过则添加到请求头并放行路由到微服务,不通过则说明过期或者被篡改, 封装响应码通知前端
(3)前端接收通过,将token(长)发送给后端请求刷新token的请求,后端检验token(长),验证通过则重新生成两个token返回给前端,然后添加到请求头并放行路由到微服务,不通过则说明过期或者被篡改
(4)Token续期处理
用户登录完成以后,服务端会生成两个token,一个是有效期比较短的accesstoken,比如2小时,还有一个是有效期长的refreshToken,比如30天,客户端会把这两个token都保存到客户端本地存储
客户端在随后访问服务端接口的时候,需要在header中携带accesstoken。请求首先是到网关,网关会做token过期时间的判断,如果token没过期则正常放行到后端的微服务,如果token已经过期,网关会返回一个特殊的代表token过期的响应码4001,客户端收到4001状态码的响应以后,不会向客户端提示失败,而目继续访问服务端提供的refresh token的请求,这个请求会返回一个新的accesstoken和refreshtoken,客户端会继续使用这个新的accesstoken去访问服务端的接口。
(5)Token登出处理
生成token的时候,会给token设置一个token id
当用户退出的时候,把token id存放到redis中,key: token id,value:userld,有效期设置2小时
网关收到请求的时候,首先是根据token id去redis中查询,如果能查到值,说明token已经退出了,则返回token已经失效,如果查不到,则说明token是有效的,继续后续的业务逻辑外理。
【4】如何获取客户端的各种信息
HttpServletRequest request = ServletActionContext.getRequest();
System.out.println("浏览器基本信息:"+request.getHeader("user-agent"));
System.out.println("客户端系统名称:"+System.getProperty("os.name"));
System.out.println("客户端系统版本:"+System.getProperty("os.version"));
System.out.println("客户端操作系统位数:"+System.getProperty("os.arch"));
System.out.println("HTTP协议版本:"+request.getProtocol());
System.out.println("请求编码格式:"+request.getCharacterEncoding());
System.out.println("Accept:"+request.getHeader("Accept"));
System.out.println("Accept-语言:"+request.getHeader("Accept-Language"));
System.out.println("Accept-编码:"+request.getHeader("Accept-Encoding"));
System.out.println("Connection:"+request.getHeader("Connection"));
System.out.println("Cookie:"+request.getHeader("Cookie"));
System.out.println("客户端发出请求时的完整URL"+request.getRequestURL());
System.out.println("请求行中的资源名部分"+request.getRequestURI());
System.out.println("请求行中的参数部分"+request.getRemoteAddr());
System.out.println("客户机所使用的网络端口号"+request.getRemotePort());
System.out.println("WEB服务器的IP地址"+request.getLocalAddr());
System.out.println("WEB服务器的主机名"+request.getLocalName());
System.out.println("客户机请求方式"+request.getMethod());
System.out.println("请求的文件的路径"+request.getServerName());
System.out.println("请求体的数据流"+request.getReader());
BufferedReader br=request.getReader();
String res = "";
while ((res = br.readLine()) != null) {
System.out.println("request body:" + res);
}
System.out.println("请求所使用的协议名称"+request.getProtocol());
System.out.println("请求中所有参数的名字"+request.getParameterNames());
Enumeration enumNames= request.getParameterNames();
while (enumNames.hasMoreElements()) {
String key = (String) enumNames.nextElement();
System.out.println("参数名称:"+key);
}