【Jwt】详解认证登录的数字签名

【Jwt】详解认证登录的数字签名

【一】jwt概念

【1】什么是jwt

Json Web Token是通过数字签名的方式,以json为载体,在不同的服务之间安全的传输信息的技术。

【2】jwt有什么用

一般使用在授权认证的过程中,一旦用户登录,后端返回一个token给前端,相当于后端给了前端返回了一个授权码,之后前端向后端发送的每一个请求都需要包含这个token,后端在执行方法前会校验这个token(安全校验),校验通过才执行具体的业务逻辑。

【3】jwt的组成结构

JWT由Header(头信息),PayLoad (用户信息),Signature(签名)三个部分组成

(1)Header:头信息

Header头信息主要声明加密算法:(具体算法对称不对称加密不作为研究内容)

通常直接使用 HMAC HS256这样的算法

json 复制代码
{
  "typ":"jwt"
  "alg":"HS256" //加密算法
}

然后将头部进行base64加密(该加密是可以对称解密的),构成了第一部分

(2)PayLoad:用户信息

指定了七个默认字段供选择

java 复制代码
iss:发行人
exp:到期时间
sub:主体
aud:用户
nbf:在此之前不可用
iat:发布时间
jti:JWT ID用于标识该JWT

也可以自定义私有字段,但是不建议在此存放密码之类的敏感信息,因为此部分可以解码还原出原始内容。虽然它可以解码,但是也不能修改这个内容。

json 复制代码
{
  "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】引入依赖

xml 复制代码
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt</artifactId>
    <version>0.9.1</version>
    <scope>provided</scope>
</dependency>

【1】生成token

java 复制代码
    // 秘钥
    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

java 复制代码
    // 从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】验证逻辑

java 复制代码
    // 验证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】完整工具类

java 复制代码
@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传递给下游的微服务即可。

如何传递?

java 复制代码
// 把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】如何获取客户端的各种信息

java 复制代码
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);
}
相关推荐
28岁青春痘老男孩2 小时前
JDK8+SpringBoot2.x 升级 JDK 17 + Spring Boot 3.x
java·spring boot
ruleslol3 小时前
MySQL的段、区、页、行 详解
数据库·mysql
天若有情6733 小时前
校园二手交易系统实战开发全记录(vue+SpringBoot+MySQL)
vue.js·spring boot·mysql
while(1){yan}3 小时前
MyBatis Generator
数据库·spring boot·java-ee·mybatis
奋进的芋圆3 小时前
DataSyncManager 详解与 Spring Boot 迁移指南
java·spring boot·后端
それども3 小时前
MySQL affectedRows 计算逻辑
数据库·mysql
是小章啊3 小时前
MySQL 之SQL 执行规则及索引详解
数据库·sql·mysql
计算机程序设计小李同学3 小时前
个人数据管理系统
java·vue.js·spring boot·后端·web安全
富士康质检员张全蛋3 小时前
JDBC 连接池
数据库
yangminlei4 小时前
集成Camunda到Spring Boot项目
数据库·oracle