SpringBoot简单集成JWT

jwt-rclt.png

1. JWT入门

1.1 JWT概念

官方网站:https://jwt.io/introduction/
JSON Web Token(JWT)是一个定义在 RFC 7519 开放标准下的技术,提供了一种紧凑且自包含的方式用于在各方之间安全地传输信息。JWT 使用 JSON 对象作为载体,同时通过数字签名来验证和确保信息的可信度。数字签名可以通过秘密密钥(HMAC 算法)或是公钥/私钥对(使用 RSA 或 ECDSA)生成。

说简单点就是:JWT 是一种通过 JSON 形式作为 Web 应用中的令牌(Token),能够在各方之间安全地将信息作为 JSON 对象传输,并可以在传输过程中完成数据加密、签名等相关处理。

1.2 JWT 应用场景

授权(Authorization):JWT 常用于用户授权。一旦用户登录成功就可以获得一个令牌(Token),后续的每个请求都将在其请求头中携带该令牌(Token),以允许用户访问令牌(Token)授权的路由、服务和资源等。由于 JWT 的小开销和跨域能力,JWT 被广泛应用于单点登录(Single Sign-On)。
信息交换(Information Exchange):JWT 提供了一种安全的方式在各方之间传输信息。JWT 可以通过数字签名来确定发送者的身份。同时,由于数字签名是根据标头和有效负载计算的,所以还可以验证内容是否被篡改过。

1.3 为何选择 JWT

基于 Session 的传统认证
HTTP 协议本身是无状态的,这意味着用户每次发出请求时都必须进行身份验证。为了使应用能识别是哪个用户发出的请求,我们只能在服务器端的 Session 域中存储一份用户登录的信息。这份登录信息会以 SessionID 的形式在响应时传递给浏览器进行缓存,并告诉其保存为 Cookie,以便下次请求时发送给服务端进行身份验证。
031e1350421f5041edc1d0e7c0bc74f8-czdo.png

然而,这种方法有一些不可避免的问题:

每个经过身份认证的用户都会在服务器端 Session 域中存储一条记录。随着认证用户的增多,服务端的开销会显著增大;
认证记录通常保存在服务器内存中,这意味着用户下次请求必须发送到同一台服务器,这在分布式应用中限制了负载均衡器的能力;同时,如果后端应用是集群多节点部署,就需要实现 Session 共享机制,这给集群应用带来了不便。
携带 SessionID 的 Cookie 容易被截获,用户可能会受到跨站请求伪造(CSRF)攻击;
在前后端分离的系统中,用户的每次请求都需要通过代理(如 Nginx)转发多次,并在服务器上查询用户信息。这将给服务器带来额外的负担,并增加部署的复杂性。
基于 JWT 的认证
用户首次登录成功后,服务器会返回一个令牌(Token)。用户随后每次请求受保护资源时,都需要在 HTTP 请求头(Request Header)中添加一个 Authorization 字段,字段值就是 Bearer 加上此令牌(Token)。服务器通过对 Authorization 字段值信息(也就是 Token)的解码及数字签名校验来获取其中的用户信息,从而实现认证和授权。(“Bearer” 中文翻译可以理解为 “携带” 的意思)

下面是一个请求头中的 Authorization 字段携带 Token 的示例:

Authorization: Bearer <token>

下面是 JWT 官方给的工作原理图:
280d2eefbd2daad2beecf9748e8ee396-zmhp.png
看起来过于草率了,下面是一个扩展后的原理图:
3b962ba79a24979ccc67153811d68ca3-wumf.png
基于 JWT 的认证避免了传统 Session 认证机制存在的问题:

  1. 客户端发起认证请求:用户通过登录表单将用户名和密码发送到后端的接口,这一过程通常是一个 HTTP POST 请求。为避免敏感信息被嗅探,建议使用 SSL 加密传输(HTTPS 协议)。
  2. 服务端生成令牌(Token):服务端在核对用户名和密码成功后,会将用户的 id 等其他信息作为 JWT Payload(负载),并签名生成一个 JWT (Token),形成的 JWT 是一个形如 xxx.yyy.zzz 的字符串。(下面马上会介绍)
  3. 前端保存令牌(Token):JWT 字符串作为登录成功的返回结果返回给前端,前端将返回的结果保存在本地浏览器的 localStorage 或 sessionStorage 中。
  4. 后续请求携带令牌(Token):后续用户每次请求服务端资源时,都需要将 JWT 放入 HTTP Header 的 Authorization 位(Bearer + Token),避免了 XSS(跨站脚本攻击)和 CSRF(跨站请求伪造)问题。
  5. 服务端拦截请求解析并验证令牌(Token):后端会拦截请求,检查请求头中是否携带令牌(Token),如果存在则进行解析并验证其有效性。例如,检查签名是否正确,检查 Token 是否过期,检查 Token 的接收方是否是自己等(可选)。
  6. 响应结果:验证通过后,后端使用 JWT 中包含的用户信息进行其他逻辑操作,返回相应结果。

1.4 JWT 的结构

JWT由三个以 “·” 为分隔符的部分组成:标头(Header)、载荷(Payload)以及签名(Signature)。header.payload.signature

标头(Header)
标头(Header)主要包含两个信息:令牌类型(typ)和所使用的加密算法(alg),例如 HS256 或者 RSA。将这部分信息采用 JSON 格式存储,然后通过 Base64 编码处理,就构成了 JWT 的第一部分。例如:

{
  "alg": "HS256",
  "typ": "JWT"
}

载荷(Payload)
载荷(Payload)部分包含了所要传递的数据,通常这些数据都是一些声明(claims),例如用户身份信息、token 的生成时间、过期时间等。载荷也需要进行 Base64 编码,形成 JWT 的第二部分。例如:

{
  "sub": "1234567890",
  "name": "John Doe",
  "created": 1489079981393,
  "exp": 1489684781
  "admin": true
}

签名(Signature)
签名(Signature)部分是用于验证消息在传输过程中未被篡改,以及验证令牌发送者的身份。签名部分需要使用 header,payload,密钥,以及 header 中声明的加密方式(如 HS256)共同生成。例如:

HMACSHA256(
  base64UrlEncode(header) + "." +
  base64UrlEncode(payload),
  secret)

这样,JWT 的最终形式是三部分通过 “.” 连接的 Base64-URL 字符串。它不仅适用于在 HTML 和 HTTP 环境中传输,而且比基于 XML 的标准(如 SAML)更为简洁。

--
下面是一个最终的 JWT 字符串实例:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

我们可以在该网站上获得解析结果:https://jwt.io/
2e0e775ae203bbc36a288dee788fc9d4-qdty.png

1.5 RBAC (Role-Based Access Control)

RBAC,即基于角色的访问控制,是一种常用的企业安全策略。在 RBAC 中,权限和角色相关联,用户通过成为对应角色的成员而获得权限。

RBAC 的核心概念包括:

  • 用户(User):系统的使用者。
  • 角色(Role):系统中职责的抽象。
  • 权限(Permission):对系统资源的访问能力。
    用户和角色、角色和权限、用户和权限之间都可以是多对多的关系,所以 RBAC 可以实现非常细致和灵活的权限管理。
    3fa1ea7d4a91b8836fafa7540f77721b-sbfm.png
    RBAC 有许多优点:
  • 简化管理:只需定义角色和权限,然后为用户分配角色即可。
  • 增强用户 --> 角色 --> 权限
    用户可以拥有一个或多个角色,角色可以包含一个或多个权限。当用户尝试访问系统资源时,系统将根据用户的角色和角色所拥有的权限来判断用户是否有权限进行操作。

1.6 JWT 基本使用

添加依赖
在项目的 pom.xml 文件中添加相关依赖:

<!-- JWT 支持 -->
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt</artifactId>
    <version>0.9.1</version>
</dependency>

生成 Token

@Test
void testGetToken() {
    // 创建 Token 过期时间(7 天)
    Calendar calendar = Calendar.getInstance();
    calendar.add(Calendar.DATE, 7);

    // 创建有效载荷中的声明
    Map<String,Object> claims = new HashMap<>();
    claims.put("sub", "javgo"); // 用户名
    claims.put("created", new Date()); // 创建时间
    claims.put("roles", "admin"); // 角色
    claims.put("authorities", "admin"); // 权限
    claims.put("id", 1); // 用户 ID

  	// 生成 Token 
    String token = Jwts.builder()
                    .setHeaderParam("typ", "JWT") // 设置 Token 类型(默认是 JWT)
                    .setHeaderParam("alg", "HS256") // 设置签名算法(默认是 HS256)
                    .setClaims(claims) // 设置有效载荷中的声明
                    .signWith(SignatureAlgorithm.HS256, "hags213#ad&*sdk".getBytes()) // 设置签名使用的密钥和签名算法
                    .setExpiration(calendar.getTime()) // 设置 Token 过期时间
                    .compact();

    System.out.println(token);
}

执行结果如下:

eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJqYXZnbyIsImNyZWF0ZWQiOjE2OTAwMDkyMjk3NzksInJvbGVzIjoiYWRtaW4iLCJpZCI6MSwiZXhwIjoxNjkwNjE0MDI5LCJhdXRob3JpdGllcyI6ImFkbWluIn0.-VYyJNemNB0XS2Qk3Ai77MirRPobyZ0EnQgoKiv9IXE

Base64 解析结果如下:
466c421ff243948e5e9e47ddf2e2db59-osnk.png
解析 Token

@Test
void analysisToken(){
    Claims claims = Jwts.parser() // 解析
            .setSigningKey("hags213#ad&*sdk".getBytes()) // 设置密钥(会自动推断算法)
            .parseClaimsJws("eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJqYXZnbyIsImNyZWF0ZWQiOjE2OTAwMDkyMjk3NzksInJvbGVzIjoiYWRtaW4iLCJpZCI6MSwiZXhwIjoxNjkwNjE0MDI5LCJhdXRob3JpdGllcyI6ImFkbWluIn0.-VYyJNemNB0XS2Qk3Ai77MirRPobyZ0EnQgoKiv9IXE") // 设置要解析的 Token
            .getBody();// 获取有效载荷中的声明

    System.out.println("用户名:" + claims.get("sub"));
    System.out.println("创建时间:" + claims.get("created"));
    System.out.println("角色:" + claims.get("roles"));
    System.out.println("权限:" + claims.get("authorities"));
    System.out.println("用户 ID:" + claims.get("id"));
    System.out.println("过期时间:" + claims.getExpiration());
}

执行结果如下:

用户名:javgo
创建时间:1690009229779
角色:admin
权限:admin
用户 ID:1
过期时间:Sat Jul 29 15:00:29 CST 2023

2.JWT实践

2.1背景介绍

公司目前有一个多年前以springboot为框架开发的可视化项目,其中登陆模块是另一个部门负责的,主要负责统一的登陆管理。所以其它子项目登陆时,会跳转到登陆管理项目进行统一登录。登陆成功之后管理后台会返回一个ticket标识给子项目用来验证是否已经登录成功。如果登陆成功则正常进入系统。
这套流程虽然解决了登陆问题,但是子项目的接口部分却完全暴露在外,外部人员可以轻易的通过api调用的方式调取后台数据而不需要任何校验,所以我与项目组讨论后准备给子项目添加JWT校验,防止API被滥用。

2.2 代码编写

添加pom依赖:

<!--jwt-->
<dependency>
  <groupId>io.jsonwebtoken</groupId>
  <artifactId>jjwt</artifactId>
  <version>0.9.1</version>
</dependency>

当登陆系统返回ticket并校验成功后,生成Token并存入session中返回到前端

                    try {
                        logger.info("开始生成JWT令牌");
                        // 生成JWT令牌
                        int EXPIRATION_TIME = 1000 * 60 * 60 * jwtExpireTime; //默认token过期时间24h
                        String jwtSecret = "abcdefg";
                        String jwtToken = Jwts.builder()
                                .setSubject(userName)  //登陆用户名
                                .setIssuedAt(new Date())  //创建时间
                                .setExpiration(new Date(System.currentTimeMillis() + EXPIRATION_TIME))  //过期时间
                                .signWith(SignatureAlgorithm.HS512, jwtSecret)  //加密密钥
                                .compact();
                        sessionMap.put("jwtToken", jwtToken);
                        logger.info("生成JWT令牌执行完毕");
                    } catch (Exception e) {
                        logger.error("生成JWT令牌失败", e);
                    }

前端收到后台返回的Token后将其存入到localStorage中,并加到调用后台api的请求header,由于我们使用的是js,以axios方法为例

// 设置全局的 Authorization 头部信息
    axios.defaults.headers.common['Authorization'] = 'Bearer ' + localStorage.getItem('jwtToken');

后台编写拦截器,获取所有api请求中的header并校验

@Override
    public boolean preHandle(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Object o) throws Exception {
        ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
        HttpServletRequest request = attributes != null ? attributes.getRequest() : null;
        // 获取http请求中的jwt令牌
        if (request != null) {
            try {
                String authorizationHeader = request.getHeader("Authorization");
                String jwtSecret = "abcdefg";
                // 检查Token是否存在
                if (authorizationHeader != null && authorizationHeader.startsWith("Bearer ")) {
                    String jwtToken = authorizationHeader.replace("Bearer ", "");
                    // 解析和验证JWT
                    Claims claims = Jwts.parser()
                            .setSigningKey(jwtSecret)
                            .parseClaimsJws(jwtToken)
                            .getBody();
                    // 从 JWT 中获取数据
                    String username = claims.getSubject();  //获取加密中的用户名,与已登录账户的名称进行匹配,此处校验方法由于涉及多个系统,所以隐去,可以按照自己系统的情况进行修改
                    Date expirationTime = claims.getExpiration();  //获取加密中的过期时间
                    // 判断过期时间是否大于当前时间
                    if (expirationTime.before(new Date())) {
                        throw new RuntimeException("JWT 令牌已过期!");
                    }
                    return true;
                }
            } catch (JwtException e) {
                // 处理JWT解析异常
                throw new RuntimeException("无效的JWT令牌!", e);
            } catch (Exception e) {
                // 处理其他可能的异常情况
                throw new RuntimeException("发生异常:" + e.getMessage(), e);
            }
        }
        throw new RuntimeException("暂无权限");
    }

    @Override
    public void postHandle(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Object o, ModelAndView modelAndView) throws Exception {
    }

    @Override
    public void afterCompletion(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Object o, Exception e) throws Exception {
    }

获取使用过滤器实现,方便返回值的修改。由于我用的是拦截器,所以网上找到了一段相关代码

/**
 * JWT 登录授权过滤器
 */
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {
    private static final Logger LOGGER = LoggerFactory.getLogger(JwtAuthenticationTokenFilter.class);

    // 用户详细信息服务(用于从数据库中加载用户信息,需要自定义实现)
    @Autowired
    private UserDetailsService userDetailsService;

    // JWT 工具类
    @Autowired
    private JwtTokenUtil jwtTokenUtil;

    // JWT 令牌请求头(即:Authorization)
    @Value("${jwt.tokenHeader}")
    private String tokenHeader;

    // JWT 令牌前缀(即:Bearer)
    @Value("${jwt.tokenHead}")
    private String tokenHead;

    /**
     * 从请求中获取 JWT 令牌,并根据令牌获取用户信息,最后将用户信息封装到 Authentication 中,方便后续校验(只会执行一次)
     * @param request 请求
     * @param response 响应
     * @param filterChain 过滤器链
     * @throws ServletException Servlet 异常
     * @throws IOException IO 异常
     */
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        // 从请求中获取 JWT 令牌的请求头(即:Authorization)
        String authHeader = request.getHeader(this.tokenHeader);
        
        // 如果请求头不为空,并且以 JWT 令牌前缀(即:Bearer)开头
        if (authHeader != null && authHeader.startsWith(this.tokenHead)){
            // 获取 JWT 令牌的内容(即:去掉 JWT 令牌前缀后的内容)
            String authToken = authHeader.substring(this.tokenHead.length());
            // 从 JWT 令牌中获取用户名
            String username = jwtTokenUtil.getUserNameFromToken(authToken);
            // 记录日志
            LOGGER.info("checking username:{}", username);
            
            // 如果用户名不为空,并且 SecurityContextHolder 中的 Authentication 为空(表示该用户未登录)
            if (username != null && SecurityContextHolder.getContext().getAuthentication() == null){
                // 从数据库中加载用户信息
                UserDetails userDetails = userDetailsService.loadUserByUsername(username);
                
                // 如果 JWT 令牌有效
                if (jwtTokenUtil.validateToken(authToken,userDetails)){
                    // 将用户信息封装到 UsernamePasswordAuthenticationToken 对象中(即:Authentication)
                    // 参数:用户信息、密码(因为 JWT 令牌中没有密码,所以这里传 null)、用户权限
                    UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(userDetails,null,userDetails.getAuthorities());
                    // 将请求中的详细信息(即:IP、SessionId 等)封装到 UsernamePasswordAuthenticationToken 对象中方便后续校验
                    authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
                    // 记录日志
                    LOGGER.info("authenticated user:{}", username);
                    // 将 UsernamePasswordAuthenticationToken 对象封装到 SecurityContextHolder 中方便后续校验
                    SecurityContextHolder.getContext().setAuthentication(authentication);
                }
            }
        }
        // 放行,执行下一个过滤器
        filterChain.doFilter(request,response);
    }
}

上述 doFilterInternal 方法的处理逻辑总结如下:

  1. 从 HTTP 请求头中获取 JWT 令牌。
  2. 检查该令牌是否存在并且是否以指定的前缀(如 "Bearer ")开头。
  3. 如果满足上述条件,从令牌中提取用户名。
  4. 如果用户名存在,并且当前的 SecurityContextHolder 中没有 Authentication(表示用户尚未登录),则继续处理。
  5. 使用 UserDetailsService 从数据库中加载与该用户名对应的用户详细信息。
  6. 使用 JwtTokenUtil 验证 JWT 令牌是否有效。
  7. 如果令牌有效,创建一个 UsernamePasswordAuthenticationToken 对象,其中包含用户的详细信息、权限等,并将其设置到 SecurityContextHolder 中,这样后续的请求处理可以知道当前的用户是谁。
  8. 最后,放行请求,使其继续执行下一个过滤器或进入目标处理程序。

至此,JWT的校验功能已经实现,如果接口调用不携带Token,则无法获取数据,你也可以自定义认证失败的返回值。

自定义 AuthenticationEntryPoint
先补充一些前导知识:

org.springframework.security.web.AuthenticationEntryPoint 是 Spring Security 中的一个函数式接口,它定义了一个方法 commence。这个接口主要用于处理认证失败的情况,例如当用户尝试访问一个受保护的资源但没有提供有效的凭证(一般密码,这里便是 Token)时。

public interface AuthenticationEntryPoint {
    /**
     * 处理认证失败的情况
     * @param request      表示客户端请求的信息
     * @param response     表示服务器对客户端请求的响应
     * @param authException 表示身份验证过程中发生的异常
     * @throws IOException      IO 异常
     * @throws ServletException Servlet 异常
     */
    void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException)
            throws IOException, ServletException;
}

此方法的任务是在认证失败时修改 HTTP 响应,其默认实现为 LoginUrlAuthenticationEntryPoint。默认情况下,Spring Security 会重定向用户到登录页面。

但是,在某些应用场景中,例如前后端分离的 RESTful web services,可能更希望返回一个错误代码和消息,而不是重定向到登录页面。

当使用 JWT 作为认证机制时,通常的流程是:

  1. 用户首先使用用户名和密码登录。
  2. 服务器验证用户名和密码,如果验证成功,则返回一个 JWT。
  3. 在后续的请求中,用户将 JWT 作为请求的一部分(通常是在请求头中)发送给服务器。
  4. 服务器验证 JWT,并根据 JWT 中的信息确定用户的身份。
  5. 在这种情境下,当 JWT 无效或过期时,我们不希望重定向用户到登录页面,而是希望返回一个明确的错误消息,例如 “Token is invalid” 或 “Token has expired”。

因此,我们需要实现 AuthenticationEntryPoint 并重写 commence 方法。在 commence 方法中,我们可以自定义响应,返回适当的 HTTP 状态码(如 401 Unauthorized)和一个明确的错误消息。

下面是我们需要自定义的认证失败返回逻辑 RestAuthenticationEntryPoint:

/**
 * 自定义认证失败处理:没有登录或 token 过期时
 */
public class RestAuthenticationEntryPoint implements AuthenticationEntryPoint {

    /**
     * 当认证失败时,此方法会被调用
     * @param request 请求对象
     * @param response 响应对象
     * @param authException 认证失败时抛出的异常
     * @throws IOException IO 异常
     * @throws ServletException Servlet 异常
     */
    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
        // 设置响应头,允许任何域进行跨域请求
        response.setHeader("Access-Control-Allow-Origin", "*");
        // 设置响应头,指示响应不应被缓存
        response.setHeader("Cache-Control","no-cache");
        // 设置响应的字符编码为 UTF-8
        response.setCharacterEncoding("UTF-8");
        // 设置响应内容类型为 JSON
        response.setContentType("application/json");
        // 将认证失败的消息写入响应体
        response.getWriter().println(JSONUtil.parse(CommonResult.unauthorized(authException.getMessage())));
        // 刷新响应流,确保数据被发送
        response.getWriter().flush();
    }
}

处理逻辑总结如下:

  1. 当用户尝试访问受保护的资源但未提供有效的凭证或 JWT 令牌无效时,会抛出 AuthenticationException 异常而自动回调 AuthenticationEntryPoint 的 commence 方法进行处理。
  2. 为了确保响应可以在跨域的情况下被前端接收,设置了 “Access-Control-Allow-Origin” 为 “*”,这意味着任何域都可以接收此响应。2
  3. 为了确保响应不被客户端缓存,设置了 “Cache-Control” 为 “no-cache”。这是为了确保客户端总是从服务器获取最新的响应,而不是使用旧的、可能已经过时的缓存数据。
  4. 响应的内容类型被设置为 JSON,字符编码被设置为 UTF-8。
  5. 使用 JSONUtil.parse(Hutool 工具包将对象转为 JSON 的工具类)和 CommonResult.unauthorized(自定义的通用返回对象)将认证失败的消息转换为 JSON 格式并写入响应体。
  6. 最后,刷新响应流,确保所有数据都被发送到客户端。
为什么设置特定的响应头:
- Access-Control-Allow-Origin=*:在前后端分离的应用中,前端和后端可能运行在不同的域上。为了允许前端从不同的域请求后端资源,我们需要设置此响应头。但在生产环境中,通常建议设置具体的域名而不是使用通配符 *,以增加安全性。
- Cache-Control=no-cache:为了确保客户端总是接收到最新的认证失败消息,而不是使用可能已经过时的缓存数据,我们需要设置此响应头。这对于安全相关的响应尤为重要,因为我们不希望旧的或不准确的安全消息被缓存并显示给用户。

参考资料:
https://blog.csdn.net/ly1347889755/article/details/132609255

posted on 2024-02-04 10:56  littecow  阅读(312)  评论(0编辑  收藏  举报

导航