9_springboot_shiro_jwt_多端认证鉴权_整合jwt

1. Shiro框架回顾

到目前为之,Shiro框架本身的知识点已经介绍完了。web环境下,整个框架从使用的角度我们需要关注的几个点:

  1. 要使用Shiro框架,就要创建核心部件securityManager 对象。 SpringBoot项目中,引入shiro-spring-boot-web-starter 组件后,其自动配置会自动创建securityManager 对象,默认创建的是org.apache.shiro.web.mgt.DefaultWebSecurityManager , 一般项目web项目使用它已经够用,如果有特殊要求我们自己可以定制

  2. securityManager 对象要能够正常工作,我们需要为它准备:

    • subjectDAO 一般情况下,我们无需配置这个对象,默认使用的是DefaultSubjectDAO, 看名字好像是Subject对象的存储,其实Subject对象每次访问都会创建一个新的subject对象,并会绑定到当前线程中。其实它所做的事情并不是存储Subject,而是将当前Subject中的身份信息:principalauthenticated 是否校验成功放入到session 中去而已。

      subjectDAO 工作的时候,需要为它提供SessionStorageEvaluator 默认提供的是 DefaultWebSessionStorageEvaluator, 它决定了subjectDAO 是否要将subject中的信息写入到session中。

      如果确实不需要写入session,比如某些场景下每次都要验证,就没有必要写入到session中了,此时可以自定义类,继承DefaultWebSessionStorageEvaluator 类,重写 isSessionStorageEnabled 方法,在这个方法中根据情况返回是否开启session存储操作。

      本章代码中自定义了一个 SessionStorageEvaluator 让api请求认证完毕后,不存入session。

    • subjectFactory : 创建subject对象的工厂类,默认是DefaultWebSubjectFactory, 创建的是 WebDelegatingSubject, 如果项目中确实需要,也可以定制

    • authenticator 认证器,默认为:ModularRealmAuthenticator, ModularRealmAuthenticator 默认的认证策略是AtLeastOneSuccessfulStrategy. 无特殊需求,一般也无需定制,使用默认即可

    • authorizer 授权器,默认为: ModularRealmAuthorizer

    • realm 绝大多数情况下,我们需要定义自己的realm ,可以定义多个

    • sessionManager Session管理器,前面代码中我们进行了定制

    • cacheManager 缓存管理器,前面代码中我们使用了redis作为缓存

  3. realm 配置realm的时候关注是否开启缓存,缓存的key是什么

  4. sessionManager 为他配置sessionDAO. 前面我们自定义了sessionDAO,将session数据保存到了redis中。

  5. 过滤器相关

    shiroFilterFactoryBean 实际类型为 ShiroFilterFactoryBean, 它的作用就是创建了一个Filter,并将这个Filter注册到Servlet容器中。同时它也管理着系统默认的Filter和自定义的Filter。 它需要:

    1. securityManager
    2. filterChainDefinitionMap : 即URL与过滤器名称之间的映射
    3. filters 即自定义过滤器,名称和过滤器实例对象之间的映射

2. 目标

本节先了解一些jwt 的基本知识,然后讨论一下 将JWT整合到shiro中的必要性,最后看如何通过代码来将两者整合到一起。

3. JWT介绍

JSON web token (JWT)是认证的一种方式,相比于基于Session认证,在系统中并不存储任何关于用户信息。

3.1 JWT格式

一个JWT实际上就是一个字符串,它由三部分组成,头部载荷签名,每个部分用 . 进行分割

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6InVzZXIxIiwiZXhwIjoxNTQ3OTc0MDgyfQ.2Ye5_w1z3zpD4dSGdRp3s98ZipCNQqmsHRB9vioOx54
  • 头部 Header

    第一部分是头部(eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9)。头部信息是生成签名的算法,这部分非常标准,对于任何使用相同算法的JWT都是一样的。

  • 载荷 Payload

    第二部分是有效载荷(payload)(eyJ1c2VybmFtZSI6InVzZXIxIiwiZXhwIjoxNTQ3OTc0MDgyfQ),它包含特定应用程序信息(在这个示例中是用户名),以及关于token有效期和有效性的信息。

  • 签名 Signature

    第三部分是签名(2Ye5_w1z3zpD4dSGdRp3s98ZipCNQqmsHRB9vioOx54)。它是通过将前两部分与一个密钥合并和散列而生成的。

是头部和有效载荷是不加密的.仅使用base64进行编码,意味着任何人都能解码看到内容,

Linux中使用命令可以查看:

echo eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9 | base64 -d
{"alg":"HS256","typ":"JWT"}

在这里插入图片描述

3.2 jwt中三个部分相关字段

  • Header(头部):描述JWT的基本元数据,包括使用的加密算法(用于签名或加密)以及JWT的类型,相关字段:

    • alg(algorithm)

      指定签名或加密算法,如 HS256(HMAC with SHA-256)、RS256(RSA with SHA-256)、ES256(ECDSA with SHA-256)等。这个字段告知接收方如何对JWT的Signature部分进行验证.

    • typ (type)

      声明数据类型,对于JWT而言,通常固定为 JWT,表明这是一个JSON Web Token。

    示例:

    {
      "alg": "HS256",
      "typ": "JWT"
    }
    
  • Payload(载荷):承载实际要传输的数据,即声明(claims)。这些声明是关于JWT所代表实体(如用户、设备、服务等)的一系列键值对,包含身份验证、授权和其他相关信息。相关字段包含

    • 标准(Standard) 或 公开(Public) claim:
      • iss(issuer):颁发者,标识JWT的签发主体。
      • sub(subject):主题,标识JWT所代表的主体,即其所有者。
      • aud(audience):受众,标识JWT预期的接受者。
      • exp(expiration time):过期时间,定义JWT的有效期截止时间。
      • iat(issued at):发行时间,记录JWT的创建时间。
      • jti(JWT ID):JWT唯一标识符,有助于防止重放攻击。
    • 私有(Private)claim:自定义的键值对,用于承载特定业务相关的数据,如用户ID、角色、权限、个人信息等。

    示例:

    {
      "sub": "1234567890",
      "name": "John Doe",
      "iat": 1516239022,
      "custom_claim": "example_value"
    }
    
  • Signature(签名): 用于验证JWT完整性和防篡改,通过对Header和Payload进行签名来保证其内容未被更改。Signature的计算过程基于Header中指定的加密算法.Signature本身不是一个JSON对象,而是通过加密算法生成的一个字节序列,经过Base64URL编码后作为JWT的第三个部分。

    示例:

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

3.3 认证步骤

  1. 登录成功生成Token,发送给客户端。 生成的时候根据实际场景需要自定义 Payload内容
  2. 客户端放入到请求头Authorization-Token 中。 客户端可以解析Payload内容(将base64格式的 payload还原成字符串即可)
  3. 在需要身份的URL上携带请求头Authorization-Token 发送到服务端
  4. 服务端请求到来时,先被中间件进行拦截,获取到Authorization-Token 请求头中的token信息后,解析这个token,解析其实就是取出 Header部分和Payload部分,然后用秘钥进行SHA256进行签名,将签名的结果和Token中的Signature进行比较,一致则签名通过,放行到Controller进行处理,不一致则认证失败,直接给出认证结果响应到客户端。

Token失效有两种方案:

  1. 设置Token过期时间,如果过期,则认证不能通过。
  2. 为每个用户设置一个秘钥,并与用户一起保存到数据库或缓存中。如果需要让用户重新登录,比如退出登录或者修改密码或者重置密码后,直接更改数据库中的秘钥,即重新生成一个秘钥。这样 在验证token的时候,由于秘钥发生变化,那么验签的结果就会与原来不同,从而判定token失效

4. Shiro使用JWT的必要性

  1. 无状态服务需求

    每个服务实例应能独立处理请求,不依赖于共享的服务器端会话状态。JWT提供了这种能力,它将用户的认证信息以加密的JSON对象形式存储在客户端(通常是浏览器的Cookie或HTTP头部),每次请求时携带此Token。Shiro与JWT整合后,可以基于Token直接进行身份验证,无需在服务器端维护会话,从而适应分布式、水平扩展的场景。

  2. 跨域支持

    如果应用涉及多个子域、前端与后端分离,或者需要与其他第三方系统进行安全交互,JWT的跨域特性非常有用。由于Token包含所有必要的认证信息,可以在不同域名或接口之间自由传递,客户端只需在请求时添加Token即可,不受同源策略限制。整合Shiro与JWT,可以使系统轻松支持跨域认证与授权。

  3. 移动端友好

    在移动应用(如原生App或React Native、Flutter等跨平台应用)中,维持会话状态可能较为复杂。JWT因其无状态特性,特别适合移动端应用,客户端只需妥善保管Token并在每次请求时发送,避免了复杂的会话同步问题。与Shiro整合,可为移动应用提供一致且高效的认证授权机制。

  4. 长会话与单点登录(SSO)支持

    JWT支持设置较长的有效期,允许用户在一段时间内无需重新登录即可访问受保护资源。此外,通过在Token中嵌入用户标识和相关权限信息,可以实现单点登录(SSO)功能,用户在一个系统登录后,携带同一Token访问其他关联系统时无需再次认证。整合Shiro与JWT,可以方便地构建SSO解决方案,提升用户体验。

由于JWT将认证信息存储在客户端,服务器端验证Token时只需进行签名验证和过期检查,无需查询数据库或缓存(除非需要实时更新权限)。这减少了对后端服务的压力,特别是当用户基数大、并发请求高时,有利于提高系统的整体性能和可伸缩性。

在下面的案例中,会为每个用户都分配一个秘钥,这个秘钥是存放在数据库中的,服务端验证Token的时候,会先取得Realm中的认证信息(其中包含了秘钥),而这个认证信息是缓存在Redis中的。当更改了用户的秘钥后,要清除这个缓存,从数据库中获取,此时已经生成的JWT Token也就失效了

5. Shiro整合JWT步骤

在这个案例中,允许了一些情况下需要用到session,一些情况下需要禁用session。使用JWT不需要用到session,因为用户的信息都已经存放到JWT中了,根据情况来创建session,前面分析过,Shiro中的 subjectDAO 中有个 SessionStorageEvaluator ,它来决定是否要写入到session中。所以本节会自定义SessionStorageEvaluator 加入逻辑来适配各种情况。

5.1 自定义AuthenticationToken

使用自定义的AuthenticationToken 来封装JWT。 后面的过滤器中会创建这个JWT token。 两种场景下会创建Token:

  • 用户登录认证,需要创建JWT token
  • 认证登录成功后,会为客户端签发 JWT token,客户端请求后需要验证这个 JWT Token 签名是否正确,是否过期。

每种情况下,token中的数据是不一样的,但是有一个数据必须是存在的,那就是用户身份的唯一标识。因为登录认证需要知道谁谁在登录,而 JWT 的签名验证需要用到秘钥,而这里设计的是每个用户都有自己的秘钥,必须要有用户的唯一身份标识才能取到秘钥。所以在过滤器中创建 JWT token的时候,必须要能获取到用户唯一身份标识,例子中用的是用户名,实际场景中可以换成别的。

public class JwtAuthenticationToken implements AuthenticationToken {
    private String  jwt;
    // 是否是登录请求,realm中要使用它来判断是登录还是验证
    private boolean isLoginRequest;
    private String  userName;
    private String  password;

    // 如果是登录,使用这个构造方法
    public JwtAuthenticationToken(String username, String password) {
        this.userName = username;
        this.password = password;
        isLoginRequest = true;
    }

    // 如果是jwt验证,使用这个构造方法
    public JwtAuthenticationToken(String jwt) {
        this.jwt = jwt;
        isLoginRequest = false;
        //从jwt中解析出用户名
        this.userName = JwtUtil.getClaimFiled(jwt, "account");
    }

    public boolean isLoginRequest() {
        return this.isLoginRequest;
    }

    /**
     * 身份信息
     *
     * @return
     */
    @Override
    public Object getPrincipal() {
        return userName;
    }

    public String getJwt() {
        //获取jwt
        return jwt;
    }

    /**
     * 只用在登录中获取提交的凭证
     *
     * @return
     */
    @Override
    public Object getCredentials() {
        return password;
    }
}

5.2 过滤器

在过滤器要完成的功能:

  1. 预处理

    通过这个过滤器的请求支持跨域请求。在JWT的场景下,不需要创建session,而其它场景下又需要创建sesssion。所以是否创建session是根据场景条件来决定。所以向reqeust attribute中放一个是否需要创建session的标识,后面我们自定义的SessionStorageEvaluator 会读取这个标识,根据条件来决定是否创建session

  2. 跨域处理

    因为添加了自定义的非标准的请求头,浏览器会认为是非简单请求,此时浏览器就会发送一个OPTION

public class JwtAuthenticationFilter extends AuthenticatingFilter{
    ...
    protected boolean preHandle(ServletRequest request, ServletResponse response) throws Exception {
        HttpServletRequest httpServletRequest = WebUtils.toHttp(request);
        // 重要,不需要创建Session,就在这里指定 ConditionalSessionStorageEvaluator 中会用到
        httpServletRequest.setAttribute("SESSION_CREATION_ENABLED", false);
        HttpServletResponse httpServletResponse = WebUtils.toHttp(response);
        httpServletResponse.setHeader("Access-control-Allow-Origin", httpServletRequest.getHeader("Origin"));
        httpServletResponse.setHeader("Access-Control-Allow-Methods", "GET,POST,OPTIONS,PUT,DELETE");
        httpServletResponse.setHeader("Access-Control-Allow-Headers", httpServletRequest.getHeader("Access-Control-Request-Headers"));
        // 跨域时会首先发送一个OPTIONS请求,这里我们给OPTIONS请求直接返回正常状态
        if (httpServletRequest.getMethod().equals(RequestMethod.OPTIONS.name())) {
            httpServletResponse.setStatus(org.springframework.http.HttpStatus.OK.value());
            return false;
        }
        return super.preHandle(request, response);
    }
    ...
}
  1. 创建AuthenticationToken

    此时需要是不是登录请求,如果是登录请求,创建的token中是没有jwt token信息的。前面说过用户的身份标识是一定要有的,如果缺失,后续代码是无法执行的。所以这里抛出了异常。在下面执行认证的时候,会抓取这个异常响应给客户端。

    @Slf4j
    public class JwtAuthenticationFilter extends AuthenticatingFilter {    
       @Override
        protected AuthenticationToken createToken(ServletRequest request, ServletResponse response) throws Exception {
            // 登录请求,就验证用户名密码
            if (isLoginRequest(request, response)) {
                // 用户名,参数名称为 username,根据实际情况修改
                String userName = WebUtils.getCleanParam(request, "username");
                // 密码,根据实际情况进行修改
                String password = WebUtils.getCleanParam(request, "password");
                return new JwtAuthenticationToken(userName, password);
            } else {
    
                // 非登录请求,就验证JWT, 请求头名称为 X-Access-Token 根据实际情况修改
                String jwtHeader = WebUtils.toHttp(request).getHeader("X-Access-Token");
                if (StringUtils.isBlank(jwtHeader)) {
                    throw new JwtVerificationException("JWT token 不存在");
                }
                return new JwtAuthenticationToken(jwtHeader);
            }
        }
    }
    
  2. 执行认证

    因为需要禁用掉session,所以当请求到来时,是无法关联上session的。也就是说只要是请求都需要认证一遍,可能是登录请求,也可能是提交了JWT token的请求。因为从 AuthenticatingFilter 继承,此时一定会调用 onAccessDenied ,所以只需要重写这个方法,让它去执行登录,进而执行 Subject.login ,这样就会调用Reaml进行认证。

    本来对于 JWT token的签名验证和过期判断是可以在Filter中完成的,如果是统一的秘钥,当然可以在这里完成,不必在Reaml中做验证。但是这里每个用户的秘钥都是不一样,而秘钥在realm中能获取到,所以就把所有的认证工作交给realm 来做。

    realm是带缓存的,不必每次都去查询用户的认证信息和授权信息。所以性能上几乎没什么损失。

    @Slf4j
    public class JwtAuthenticationFilter extends AuthenticatingFilter {
        ...
        @Override
        protected boolean onAccessDenied(ServletRequest request, ServletResponse response) throws Exception {
            // 去执行 Subject.login(); 这样才会调用 realm,由realm中的匹配器来决定是执行登录验证还是 jwt验证。
            return executeLogin(request, response);
        }
        
        // 执行登录
         @Override
        protected boolean executeLogin(ServletRequest request, ServletResponse response) throws Exception {
            AuthenticationToken token = null;
            try {
                // 创建token
                token = createToken(request, response);
                // 执行登录,交给reaml去认证
                Subject subject = getSubject(request, response);
                subject.login(token);
                return onLoginSuccess(token, subject, request, response);
            } catch (JwtVerificationException e) {
                Map<String, ?> result = Map.of("code", 401, "msg", e.getMessage());
                responseJsonResult(result, response);
                return false;
            } catch (AuthenticationException e) {
                return onLoginFailure(token, e, request, response);
            }
        }
        ...
    }
    
    // 如果是登陆失败,让请求到达Controller输出错误信息。
    // 如果是JWT验证失败,直接输出错误消息
     protected boolean onLoginFailure(AuthenticationToken token, AuthenticationException e,
                                         ServletRequest request, ServletResponse response) {
            if (isLoginRequest(request, response)) {
                setFailureAttribute(request, e);
                return true; // 
            } else { // JWT验证失败
                Map<String, ?> result = Map.of("code", 401, "msg", e.getMessage());
                responseJsonResult(result, response);
                return false;
            }
        }
    
     // 将shbiro中的错误保存到 request attribute中,Controller中获取后输出错误消息
        protected void setFailureAttribute(ServletRequest request, AuthenticationException ae) {
            String className = ae.getClass().getName();
            request.setAttribute(DEFAULT_ERROR_KEY_ATTRIBUTE_NAME, className);
        }
    
     // 向客户端输出信息。
        private void responseJsonResult(Map<String, ?> result, ServletResponse response) {
            if (response instanceof HttpServletResponse res) {
                res.setContentType("application/json;charset=UTF-8");
                res.setStatus(200);
                res.setCharacterEncoding("UTF-8");
                try {
                    // 输出JSON 数据
                    res.getWriter().write(JSON.toJSONString(result));
                    res.getWriter().flush();
                    res.getWriter().close();
                } catch (Exception e) {
                    log.error(e.getMessage(), e);
                }
            }
        }
    
    

5.3 Realm

Realm 是用来获取用户认证信息和授权信息的,这里先定义一下用户对象。

5.3.1 准备数据

这里的用户对象中包含了 :账号,密码密文,秘钥,盐值,jwt唯一标识,角色和权限。

因为jwt token应用,不能让它一直有效。虽然有过期时间,但如果token已经颁发了,没到有效期,但此时如果存在泄露的风险,需要立即让它失效,所以它需要进行管理。所以这里设计了一个 jwtId,专门用于管理jwt token

@Data
@ToString
@Builder
public class JwtAccount implements Serializable {
    private String             account;//账号
    private String             pwdEncrypt;//密码密文
    private String             secretKey;// jwt 秘钥
    private String             salt;// 对密码加密的时候使用的salt值
    private String             jwtId;// jwt唯一标识
    private Collection<String> roles;// 角色
    private Collection<String> permissions;//权限

}

Realm中指定匹配的 AuthenticationToken 然后再初始化一些数据:(实际项目是放到数据库中的)

注意这里的秘钥必须是 32字符,也就是 256字节。因为 JWT生成签名的时候,用的是HmacSHA256 算法

@Slf4j
public class JwtAuthenticationRealm extends AuthorizingRealm {
     // 模拟数据库中的账号信息 key为账号
    private Map<String, JwtAccount>  jwtAccountMap = new HashedMap();
    // 一个账号可以拥有多种角色
    private Map<String, Set<String>> roles         = Map.of(
            "administrator", Set.of("admin"),//管理员
            "zhangsan", Set.of("normal") // 普通用户
    );
    // 角色权限
    private Map<String, Set<String>> permissions   = Map.of(
            "admin", Set.of("*", "*:*"), //所有权限
            "normal", Set.of("employee:write", "employee:read") //执行查看
    );
    public JwtAuthenticationRealm() {
        // 指定密码匹配器
        super(new JwtCredentialsMatcher());
        jwtAccountMap.put("administrator", JwtAccount.builder()
                .account("administrator")
                .pwdEncrypt("0b188436fd5c434e3b8ed05cfe7c107250c1ff0ac034fad089db0f017ac3cacb")
                .secretKey("cbce2d1aad0867f8317e7ebeb3427333")
                .salt("55ae2b2c63ddd6d4763e0c57bda9078e")
                .build());
        jwtAccountMap.put("zhangsan", JwtAccount.builder()
                .account("zhangsan")
                .pwdEncrypt("3bff14c4279f01892165b96afed9b40ec7f14a9de55d9564c088bad3e04d6411")
                .secretKey("cbce2d1aad0867f8317e7ebeb3427555")
                .salt("cbce2d1aad0867f8317e7ebeb3427999")
                .build());
    }
    
    // 声明它只支持 JwtAuthenticationToken
    @Override
    public boolean supports(AuthenticationToken token) {
        return token != null && JwtAuthenticationToken.class.isAssignableFrom(token.getClass());
    }  
    
    ...
}

5.3.2 匹配器

上面构造方法中指定了一个 匹配器。在这个匹配器中需要完成两种情况的匹配。一种情况是登录认证,一种情况是JWT Token 验签。这里需要用到JWT相关的库。所以项目中需要引入:

        <dependency>
            <groupId>com.auth0</groupId>
            <artifactId>java-jwt</artifactId>
            <version>4.4.0</version>
        </dependency>

再定义一个 jwt的工具类,用来完成jwt 的生成,验证

@Slf4j
public class JwtUtil {
    public static final String HEADER = "X-Access-Token";

    // 默认过期时间,单位分钟, 即一周过期
    private static final long EXPIRE = 60 * 24 * 7;

    /**
     * 生成jwt token
     */
    public static String generateToken(JwtAccount jwtAccount) {
        // 签名算法
        Algorithm ALGORITHM = Algorithm.HMAC256(jwtAccount.getSecretKey());
        //过期时间
        LocalDateTime tokenExpirationTime = LocalDateTime.now().plusMinutes(EXPIRE);
        return JWT.create()
                .withHeader(Map.of("typ", "JWT", "alg", "HS256"))
                .withSubject(jwtAccount.getAccount())  //账号为JWT所代表的主体,即其所有者
                .withExpiresAt(Timestamp.valueOf(tokenExpirationTime)) //过期时间
                .withIssuedAt(Timestamp.valueOf(LocalDateTime.now())) //发行时间
                .withJWTId(jwtAccount.getJwtId())
                .withClaim("account", jwtAccount.getAccount())
                .withArrayClaim("roles", jwtAccount.getRoles().toArray(new String[0]))
                .withArrayClaim("permissions", jwtAccount.getPermissions().toArray(new String[0]))
                .sign(ALGORITHM);
    }

    /**
     * @param token
     * @param secretKey
     * @return
     * @throws JWTVerificationException SignatureVerificationException – 签名失败.
     *                                  TokenExpiredException – 已过期.
     *                                  MissingClaimException – 确实Claim.
     *                                  IncorrectClaimException – claim不正确
     */
    public static DecodedJWT verifyJWTSignature(String token, String secretKey) throws JWTVerificationException {
        Algorithm algorithm = Algorithm.HMAC256(secretKey);
        JWTVerifier verifier = JWT.require(algorithm)
                .build();
        return verifier.verify(token);
    }


    /**
     * 获得token中的自定义信息,一般是获取token的username,无需secret解密也能获得
     *
     * @param token
     * @param filed
     * @return
     */
    public static String getClaimFiled(String token, String filed) {
        try {
            DecodedJWT jwt = JWT.decode(token);
            return jwt.getClaim(filed).asString();
        } catch (JWTDecodeException e) {
            log.error("JwtUtil getClaimFiled error: ", e);
            return null;
        }
    }

}

匹配器:

@Slf4j
public class JwtCredentialsMatcher extends CodecSupport implements CredentialsMatcher {
    // 这里要区分是登录还是 jwt验证
    @Override
    public boolean doCredentialsMatch(AuthenticationToken token, AuthenticationInfo info) {
        // 是 jwtAuthenticationToken 时候才验证
        if (token instanceof JwtAuthenticationToken jwtAuthenticationToken) {
            if (jwtAuthenticationToken.isLoginRequest()) {
                return matchLogin(jwtAuthenticationToken, info);
            } else {
                return matchJwt(jwtAuthenticationToken, info);
            }
        }
        return false;
    }

    // 登录匹配
    private boolean matchLogin(JwtAuthenticationToken token, AuthenticationInfo info) {

        // 取出真实身份信息
        Object primaryPrincipal = info.getPrincipals().getPrimaryPrincipal();
        // 如果身份信息是 SystemAccount 对象
        // 此时要注意,Realm 中要将 JwtAccount 对象放入到 AuthenticationInfo 中

        JwtAccount account    = (JwtAccount) primaryPrincipal;
        String     accountPwd = account.getPwdEncrypt();
        // 获取盐值
        String accountSalt = account.getSalt();
        // JwtAuthenticationToken 中凭证就是密码
        String tokenPwd = token.getCredentials().toString();
        //进行散列
        String tokenPwdSha = new Sha256Hash(tokenPwd, accountSalt, 2).toHex();
        return accountPwd.equals(tokenPwdSha);

    }

    // 验证JWT
    private boolean matchJwt(JwtAuthenticationToken token, AuthenticationInfo info) {

        // 取出真实身份信息
        Object primaryPrincipal = info.getPrincipals().getPrimaryPrincipal();
        // 此时要注意,Realm 中要将 SystemAccount 对象放入到 AuthenticationInfo 中
        JwtAccount account = (JwtAccount) primaryPrincipal;
        try {
            JwtUtil.verifyJWTSignature(token.getJwt(), account.getSecretKey());
            return true;
        } catch (Exception e) {
            throw new JwtVerificationException("验证签名失败,或Token已过期");
        }
    }
}

5.3.3 Realm认证

doGetAuthenticationInfo 中只需要从数据库中取得用户正确的认证信息,它的任务就完成了,并且会进行缓存。至于是否成功,这是由 匹配器来决定的。

这里我们希望的是用户登录完成之后,将用户的权限和角色信息写入到 JWT 后返回给客户端。由于Subject 中不能直接获取到授权信息,所以这里重写了 assertCredentialsMatch 方法,这个方法其实就是在调用匹配器完成验证工作,这里添加了一段逻辑: 如果是登录验证,而且登录成功了,就取出授权信息(调用getAuthorizationInfo 会将授权信息缓存),放到了 jwtAccount 中了,这样从subject 中获取的主体就是 jwtAccount ,此时它包含了授权信息。

@Slf4j
public class JwtAuthenticationRealm extends AuthorizingRealm {    
    @Override
    protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {
        // 1.从传过来的认证Token信息 是 JwtAuthenticationToken, 它里面定义了 username作为账号
        String account = token.getPrincipal().toString();
        // 2.通过用户名到数据库中获取整个用户对象
        JwtAccount jwtAccount = jwtAccountMap.get(account);
        if (StringUtils.isBlank(account) || jwtAccount == null) {
            throw new UnknownAccountException();
        }
        // 3. 创建认证信息,即用户正确的用户名和密码。
        // 四个参数:
        // 第一个参数为主体,第二个参数为凭证,第三个参数为Realm的名称
        // 因为上面将凭证信息和主体身份信息都保存在 SystemAccount中了,所以这里直接将 SystemAccount对象作为主体信息即可

        // 第二个参数表示凭证,匹配器中会从 SystemAccount中获取盐值,密码登凭证信息,所以这里直接传null。

        // 第三个参数,表示盐值,这里使用了自定义的SaltSimpleByteSource,之所以在这里new了一个自定义的SaltSimpleByteSource,
        // 是因为开启redis缓存的情况下,序列化会报错

        // 第四个参数表示 Realm的名称
        SimpleAuthenticationInfo authenticationInfo = new SimpleAuthenticationInfo(
                jwtAccount,
                null,
                new SaltSimpleByteSource(jwtAccount.getSalt()),
                getName()
        );
        return authenticationInfo;
    }
    
    
    // 调用匹配器完成匹配工作
    @Override
    protected void assertCredentialsMatch(AuthenticationToken token, AuthenticationInfo info) throws AuthenticationException {
        // 登录成功,补充 用户角色和权限信息
        JwtAuthenticationToken jwtToken = (JwtAuthenticationToken) token;
        // 一旦没有匹配上会抛出异常
        CredentialsMatcher cm = getCredentialsMatcher();

        if (!cm.doCredentialsMatch(token, info)) {
            //not successful - throw an exception to indicate this:
            String msg = "认证错误";
            throw new IncorrectCredentialsException(msg);
        }
        // 如果登录成功,则将用户角色和权限信息补充到主体中
        if (jwtToken.isLoginRequest()) {
            JwtAccount jwtAccount = (JwtAccount) info.getPrincipals().getPrimaryPrincipal();
            // 获取身份信息
            SimplePrincipalCollection simplePrincipalCollection = new SimplePrincipalCollection(Set.of(jwtAccount), this.getName());
            // 调用父类方法,可以走缓存
            AuthorizationInfo authorizationInfo = super.getAuthorizationInfo(simplePrincipalCollection);
            // 补充角色
            jwtAccount.setRoles(authorizationInfo.getRoles());
            // 补充权限信息
            jwtAccount.setPermissions(authorizationInfo.getStringPermissions());
        }

    }
    
}

5.3.4 Realm 授权

@Slf4j
public class JwtAuthenticationRealm extends AuthorizingRealm {
    ...
    @Override
    protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
        // 1. 获取用户信息
        JwtAccount account = (JwtAccount) principals.getPrimaryPrincipal();
        // 2. 获取用户角色
        Set<String> accountRoles = roles.get(account.getAccount());
        // 3. 获取角色拥有的权限
        Set<String> accountPermissions = new HashSet<>();
        accountRoles.forEach(role -> {
            accountPermissions.addAll(permissions.get(role));
        });
        // 4. 授权信息
        SimpleAuthorizationInfo authorizationInfo = new SimpleAuthorizationInfo();
        //指定角色
        authorizationInfo.setRoles(accountRoles);
        // 权限字符串
        authorizationInfo.setStringPermissions(accountPermissions);
        return authorizationInfo;
    }
    ...
}

5.4 Controller

提供了三个请求

  1. 登录。 在这里生成jwt返回给客户端,也可以获取错误信息

    @RestController
    @Slf4j
    @RequestMapping("/jwt")
    public class JwtAuthenticateController {
        @PostMapping("/login")
        public Map<String, String> login(HttpServletRequest req) {
            Subject             subject = SecurityUtils.getSubject();
            Map<String, String> map     = new HashMap<>();
            if (subject.isAuthenticated()) {
                // 主体的标识,可以有多个,但是需要具备唯一性。比如:用户名,手机号,邮箱等。
                PrincipalCollection principalCollection = subject.getPrincipals();
                JwtAccount          account             = (JwtAccount) principalCollection.getPrimaryPrincipal();
                map.put("code", "0000");
                map.put("message", "登录成功");
                map.put("token", JwtUtil.generateToken(account));
    
            } else {
                String exceptionClassName = (String) req.getAttribute(JwtAuthenticationFilter.DEFAULT_ERROR_KEY_ATTRIBUTE_NAME);
                log.error("signinError:{}", exceptionClassName);
                String error = null;
                if (UnknownAccountException.class.getName().equals(exceptionClassName)) {
                    error = "用户名/密码错误";
                } else if (IncorrectCredentialsException.class.getName().equals(exceptionClassName)) {
                    error = "用户名/密码错误";
                } else if (ExcessiveAttemptsException.class.getName().equals(exceptionClassName)) {
                    error = "登录次数过多";
                } else if (exceptionClassName != null) {
                    error = "其他错误:" + exceptionClassName;
                }
                map.put("code", "9999");
                map.put("message", error);
            }
            return map;
        }
    }
    
  2. 退出登录

    @RestController
    @Slf4j
    @RequestMapping("/jwt")
    public class JwtAuthenticateController {
            @PostMapping("/logout")
        public Map<String, String> logout() {
    
            Subject subject = SecurityUtils.getSubject();
            // 主体的标识,可以有多个,但是需要具备唯一性。比如:用户名,手机号,邮箱等。
            PrincipalCollection principalCollection = subject.getPrincipals();
            String              name                = principalCollection.getPrimaryPrincipal().toString();
            // 退出登录
            subject.logout();
            Map<String, String> resultMap = new HashMap<>();
            resultMap.put("name", name);
            resultMap.put("message", "退出登录成功");
            return resultMap;
        }
    }
    

    特别注意:退出登录后,应该让JWT失效了,此时要通过代码更换用户的秘钥,或者 jwtId等手段让旧的jwt发送过来验证的时候,不能通过验证。

  3. 首页授权访问

    @RestController
    @Slf4j
    @RequestMapping("/jwt/home")
    public class JwtHomeController {
        // 需要管理员角色才能访问
        @RequiresRoles("admin")
        @GetMapping()
        public Map<String, String> home() {
            // 现在将 subject理解成当前用户
            Subject subject = SecurityUtils.getSubject();
            // 用户凭证,简单理解成用户名
            PrincipalCollection principalCollection = subject.getPrincipals();
            String              account             = principalCollection.getPrimaryPrincipal().toString();
            // 返回结果
            return Map.of("account", account);
        }
    }
    

5.5 配置

5.5.1 自定义 ModularRealmAuthenticator

系统中的 自定义token和自定义的realm基本上是一一对应的,默认的ModularRealmAuthenticator 使用了 至少一个认证成功的策略。这里改写它是为了不使用默认测策略,不进行一个一个探测,什么样的token就交给它对应的realm去处理好了。

public class ExactMatchRealmAuthenticator extends ModularRealmAuthenticator {

    @Override
    protected AuthenticationInfo doAuthenticate(AuthenticationToken authenticationToken) throws AuthenticationException {
        assertRealmsConfigured();
        Realm realm = matchRealm(authenticationToken);
        if (realm != null) {
            return doSingleRealmAuthentication(realm, authenticationToken);
        }
        throw new AuthenticationException("无法找到匹配的Realm");
    }

    private Realm matchRealm(AuthenticationToken token) {
        Collection<Realm> realms = getRealms();

        for (Realm realm : realms) {
            if (realm.supports(token)) {
                return realm;
            }
        }
        return null;
    }
}

配置:

@Configuration
@Slf4j
public class ShiroConfiguration {
    ...
     @Bean
    protected Authenticator authenticator() {
        return new ExactMatchRealmAuthenticator();
    }
    ...
}

5.5.2 自定义SessionStorageEvaluator

前面在filter中,向request属性中放置了一个 SESSION_CREATION_ENABLED 表示是否创建session,所以这里从写DefaultWebSessionStorageEvaluator 是因为需要根据条件来决定是否创建session

public class ConditionalSessionStorageEvaluator extends DefaultWebSessionStorageEvaluator {

    // 在不需要创建session的时候,在Filter向 request中加入
    // request.getAttribute("SESSION_CREATION_ENABLED",false)
    // 禁用session创建,以提高性能
    public boolean isSessionCreationEnabled(ServletRequest request) {
        if (request != null) {
            Object val = request.getAttribute("SESSION_CREATION_ENABLED");
            if (val != null && val instanceof Boolean) {
                return (Boolean) val;
            }
        }
        //by default
        return true;
    }

    @Override
    public boolean isSessionStorageEnabled(Subject subject) {
        boolean result = super.isSessionStorageEnabled(subject);
        if (result) {
            if (subject instanceof WebSubject webSubject) {
                //web subject:
                result = isSessionCreationEnabled(webSubject.getServletRequest());
            }
        }
        return result;
    }
}

配置:

@Configuration
@Slf4j
public class ShiroConfiguration {
    ...
    @Bean
    protected SessionStorageEvaluator sessionStorageEvaluator() {
        return new ConditionalSessionStorageEvaluator();
    }
    ...
}

5.5.3 配置Realm

@Configuration
@Slf4j
public class ShiroConfiguration {
    ...
    @Bean
    public Realm jwtRealm() {
        JwtAuthenticationRealm realm = new JwtAuthenticationRealm();
        realm.setCachingEnabled(true);
        realm.setAuthenticationCachingEnabled(true);
        // 认证缓存的名字,不设置也可以,默认
        realm.setAuthenticationCacheName("shiro:authentication:jwtCache");
        realm.setAuthorizationCachingEnabled(true);
        realm.setAuthorizationCacheName("shiro:authorization:apiCache");
        return realm;
    }
    ...
}

5.5.4 配置过滤器

@Configuration
@Slf4j
public class ShiroConfiguration {
    ...
    private ShiroFilterChainDefinition shiroFilterChainDefinition() {
        DefaultShiroFilterChainDefinition chainDefinition = new DefaultShiroFilterChainDefinition();
        chainDefinition.addPathDefinition("/api/**", "apiAuthc");
        chainDefinition.addPathDefinition("/sys/**", "sysAuthc");
        chainDefinition.addPathDefinition("/jwt/**", "jwtAuthc");
        return chainDefinition;
    }
    
    private Map<String, Filter> getCustomerShiroFilter() {
        // API 认证过滤器
        ApiAuthenticationFilter apiAuthcFilter = new ApiAuthenticationFilter();

        // 用户名,密码认证过滤器
        AuthenticationFilter sysAuthcFilter = new AuthenticationFilter();
        sysAuthcFilter.setLoginUrl("/sys/login");

        // jwt 认证过滤器
        JwtAuthenticationFilter jwtAuthcFilter = new JwtAuthenticationFilter();
        //需要指定登录地址
        jwtAuthcFilter.setLoginUrl("/jwt/login");

        Map<String, Filter> filters = new HashMap<>();
        filters.put("apiAuthc", apiAuthcFilter);
        filters.put("sysAuthc", sysAuthcFilter);
        filters.put("jwtAuthc", jwtAuthcFilter);
        return filters;
    }
    
    ...
}

5.6 测试

5.6.1 登录

请求报文

POST /jwt/login HTTP/1.1
Host: 127.0.0.1:8080
Accept: */*
Host: 127.0.0.1:8080
Connection: keep-alive
Content-Type: application/x-www-form-urlencoded

username=administrator&password=admin

相应报文:

{
    "code": "0000",
    "message": "登录成功",
    "token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpYXQiOjE3MTIzMzI0ODUsInN1YiI6ImFkbWluaXN0cmF0b3IiLCJleHAiOjE3MTI5MzcyODUsImFjY291bnQiOiJhZG1pbmlzdHJhdG9yIiwicGVybWlzc2lvbnMiOlsiKjoqIiwiKiJdLCJyb2xlcyI6WyJhZG1pbiJdfQ.-xY6PA6bI1_YmIUcNwp5Bhz3WdylOtjRYn2hswFJwio"
}

5.6.2 访问home页

请求报文:将上面返回的token放入到 X-Access-Token 请求头中。

GET /jwt/home HTTP/1.1
Host: 127.0.0.1:8080
X-Access-Token: eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpYXQiOjE3MTIzMzI0ODUsInN1YiI6ImFkbWluaXN0cmF0b3IiLCJleHAiOjE3MTI5MzcyODUsImFjY291bnQiOiJhZG1pbmlzdHJhdG9yIiwicGVybWlzc2lvbnMiOlsiKjoqIiwiKiJdLCJyb2xlcyI6WyJhZG1pbiJdfQ.-xY6PA6bI1_YmIUcNwp5Bhz3WdylOtjRYn2hswFJwio
Accept: */*
Host: 127.0.0.1:8080
Connection: keep-alive

响应报文:

{
    "account": "JwtAccount(account=administrator, pwdEncrypt=0b188436fd5c434e3b8ed05cfe7c107250c1ff0ac034fad089db0f017ac3cacb, secretKey=cbce2d1aad0867f8317e7ebeb3427333, salt=55ae2b2c63ddd6d4763e0c57bda9078e, jwtId=null, roles=null, permissions=null)"
}

5.6.3 退出

请求报文:

POST /jwt/logout HTTP/1.1
Host: 127.0.0.1:8080
X-Access-Token: eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpYXQiOjE3MTIzMzI0ODUsInN1YiI6ImFkbWluaXN0cmF0b3IiLCJleHAiOjE3MTI5MzcyODUsImFjY291bnQiOiJhZG1pbmlzdHJhdG9yIiwicGVybWlzc2lvbnMiOlsiKjoqIiwiKiJdLCJyb2xlcyI6WyJhZG1pbiJdfQ.-xY6PA6bI1_YmIUcNwp5Bhz3WdylOtjRYn2hswFJwio
User-Agent: Apifox/1.0.0 (https://apifox.com)
Accept: */*
Host: 127.0.0.1:8080
Connection: keep-alive

响应报文:

{
    "name": "JwtAccount(account=administrator, pwdEncrypt=0b188436fd5c434e3b8ed05cfe7c107250c1ff0ac034fad089db0f017ac3cacb, secretKey=cbce2d1aad0867f8317e7ebeb3427333, salt=55ae2b2c63ddd6d4763e0c57bda9078e, jwtId=null, roles=null, permissions=null)",
    "message": "退出登录成功"
}

6. 总结

本节是整个系列的最终一篇文章。本节中回顾了Shiro框架的流程,使得我们对Shiro的定制化开发更加游刃有余。

首先详细的介绍了什么是jwt,我们在shiro中使用 jwt的必要性。然后通过代码完成了整合。

本章还对 SessionStorageEvaluator 做了扩展,因为JWT不再需要使用session,而同一个项目中其它认证方式可能还需要session,这就需要根据情况来决定是否创建session。处理方式是在过滤器的预处理方法中,向reqeust中提前放入SESSION_CREATION_ENABLED 这个标识,然后在SessionStorageEvaluator 中获取这个标识来决定是否创建session

还对ModularRealmAuthenticator 做了扩展,不再使用默认的策略,而是什么样的token,就使用什么样的realm ,这样如果realm多了,每一个都试探效率会变低。

代码仓库 https://github.com/kaiwill/shiro-jwt , 本节代码在 9_springboot_shiro_jwt_多端认证鉴权_整合jwt.

  • 24
    点赞
  • 31
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

paopao_wu

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值