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*/@Overridepublic Object getPrincipal() {return userName;}public String getJwt() {//获取jwtreturn jwt;}/*** 只用在登录中获取提交的凭证** @return*/@Overridepublic 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 {    @Overrideprotected 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 {...@Overrideprotected boolean onAccessDenied(ServletRequest request, ServletResponse response) throws Exception {// 去执行 Subject.login(); 这样才会调用 realm,由realm中的匹配器来决定是执行登录验证还是 jwt验证。return executeLogin(request, response);}// 执行登录@Overrideprotected boolean executeLogin(ServletRequest request, ServletResponse response) throws Exception {AuthenticationToken token = null;try {// 创建tokentoken = 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@Overridepublic 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验证@Overridepublic 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);}// 验证JWTprivate 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 {    @Overrideprotected 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;}// 调用匹配器完成匹配工作@Overrideprotected 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 {...@Overrideprotected 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 {@Overrideprotected 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 {...@Beanprotected 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 defaultreturn true;}@Overridepublic 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 {...@Beanprotected SessionStorageEvaluator sessionStorageEvaluator() {return new ConditionalSessionStorageEvaluator();}...
}

5.5.3 配置Realm

@Configuration
@Slf4j
public class ShiroConfiguration {...@Beanpublic 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-urlencodedusername=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.

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mzph.cn/news/797130.shtml

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

python小练习(ps:可评论区讨论)

1. (单选题)海龟初始坐标为&#xff08;0&#xff0c;0&#xff09;&#xff0c;让海龟往坐标原点后方移动200像素的语句是 A. turtle.penup(200)B. turtle.fd(200)C. turtle.goto(200)D. turtle.bk(200) 2. (单选题)改变海龟画笔尺寸的是 A. turtle.penwidth()B. turtle.pen…

分类预测 | Matlab实现DRN深度残差网络数据分类预测

分类预测 | Matlab实现DRN深度残差网络数据分类预测 目录 分类预测 | Matlab实现DRN深度残差网络数据分类预测分类效果基本介绍程序设计参考资料 分类效果 基本介绍 1.Matlab实现DRN深度残差网络数据分类预测&#xff08;完整源码和数据&#xff09;&#xff0c;运行环境为Matl…

智能数据采集API技术在电子商务跨境电商数字化转型中的核心作用|API数据采集接口的核心应用

随着科技的飞速发展和全球化的深入推进&#xff0c;数字化转型已经成为企业和社会发展的必然趋势。在这一背景下&#xff0c;智能数据采集技术作为数字化转型的核心驱动力&#xff0c;正发挥着越来越重要的作用。本文将从智能数据采集技术的定义、特点、应用场景以及对企业的影…

MES实施之工控机和电脑的选择

在MES项目实施过程中,经常会碰到工控机和电脑的选型问题,那么他们的区别是什么? 1、控机和普通个人电脑(PC)相比,具有以下几个区别: 1.运行环境不同:工控机通常需要在各种恶劣的工业环境中运行,如高温、高湿、强电磁干扰等,因此需要具有防尘、防水、抗干扰等特点。而…

基于Spring Boot的简历系统设计与开发

基于Spring Boot的简历系统设计与开发 开发语言&#xff1a;Java框架&#xff1a;springbootJDK版本&#xff1a;JDK1.8数据库工具&#xff1a;Navicat11开发软件&#xff1a;eclipse/myeclipse/idea 部分系统展示 前台首页界面 简历模板管理界面 用户管理界面 管理员登录界…

Debian12 使用 nginx 与 php8.2 使用 Nextcloud

最近将小服务器升级了下系统&#xff0c;使用了 debian12 的版本&#xff0c;正好试试 nginx 和 php-fpm 这种方式运行 Nextcloud 这个私有云的配置。 一、基本系统及应用安装 系统&#xff1a;debian12 x86_64 位版本最小安装&#xff0c;安装后可根据自己需求安装一些工具&…

代码随想录算法训练营Day15|二叉树Part02|层序遍历||226翻转二叉树||101对称二叉树

今日任务 层序遍历 看完本篇可以一口气刷十道题&#xff0c;试一试&#xff0c; 层序遍历并不难&#xff0c;大家可以很快刷了十道题。 题目链接/文章讲解/视频讲解&#xff1a;代码随想录 226.翻转二叉树 &#xff08;优先掌握递归&#xff09; 这道题目 一些做过的同学 理解…

二维相位解包理论算法和软件【全文翻译- 质量分布图(3.3)】

在本节中,我们将定义几个在相位解包中非常有用的质量映射。质量图是定义给定相位数据中每个像素质量或好坏的数值数组。它们对于指导第 4 章将要介绍的几种路径跟踪算法是必要的,对于第 5 章将要介绍的一些加权 L^P-norm 算法也是必要的。 我们要讨论的第一个质量图是相关图,…

SpringBoot整合Netty整合WebSocket-带参认证

文章目录 一. VectorNettyApplication启动类配置二.WebSocketServerBoot初始化服务端Netty三. WebsocketServerChannelInitializer初始化服务端Netty读写处理器四.initParamHandler处理器-去参websocket识别五.MessageHandler核心业务处理类-采用工厂策略模式5.1 策略上下文 六…

CSS-属性

&#x1f4da;详见 W3scholl&#xff0c;本篇只做快速思维索引。 CSS 背景 用于定义元素的背景效果。 background-colorbackground-imagebackground-positionbackground-repeatbackground-attachment background-color background-color 属性指定元素的背景色。 h1 {back…

蓝桥杯 十一届C++A组 字符排序 21分(运行超时)

思路&#xff1a; 1. 此题考查的冒泡排序中的交换次数&#xff0c;其实就是考察当前数与后面的逆序对个数问题。而为了最大利用位数&#xff0c;应当使每一位都不小于后面的字符&#xff0c;否则会造成一次逆序对的浪费&#xff08;贪心&#xff0c;为了使总位数最少&#xff…

c++ 指数搜索(Exponential Search)

该搜索算法的名称可能会产生误导&#xff0c;因为它的工作时间为 O(Log n)。该名称来自于它搜索元素的方式。 给定一个已排序的数组和要 搜索的元素 x&#xff0c;找到 x 在数组中的位置。 输入&#xff1a;arr[] {10, 20, 40, 45, 55} x 45 输出&#xff1a;在索…

什么样的数据库才是开发者需要的

什么样的数据库才是开发者需要的 什么是Serverless数据库Serverless数据库应该关注哪些技术要点DBA的工作会被AI取代吗什么样的数据库是你目前最需要的 其实关于数据库的话题&#xff0c;能聊的很多&#xff0c;作为开发者来说&#xff0c;单说自己接触过的或者曾经用过的数据库…

WCH恒沁单片机-CH32V307学习记录2----FreeRTOS移植

RISC-V 单片机 FreeRTOS 移植 前面用了 5 篇博客详细介绍了 FreeRTOS 在 ARM Cortex-M3 MCU 上是如何运行的。 FreeRTOS从代码层面进行原理分析系列 现在我直接用之前的 RISC-V MCU 开发板子&#xff08;CH32V307VCT6&#xff09;再次对 FreeRTOS 进行移植&#xff0c;其实也…

量身定制:选择能够解决企业问题的六西格玛培训机构

现在的培训机构太多了&#xff0c;都在打着六西格玛管理的旗号&#xff0c;甚至有很多培训机构连六西格玛管理都没有学习过&#xff0c;就敢号称自己是六西格玛管理专家。在这个鱼龙混杂的市场上&#xff0c;很多企业对于选择什么样的培训机构&#xff0c;以及如何选择一家靠谱…

C和C++有哪些异同

C和C是两种非常相似但也有一些重要区别的编程语言。以下是它们之间的主要异同点&#xff1a; 相同点&#xff1a; 基本语法&#xff1a;C是C语言的超集&#xff0c;大部分C语言代码可以在C中直接编译运行。 基本数据类型&#xff1a;两者都具有相似的基本数据类型&#xff0c…

AOP实现接口加解密

接口加解密&#xff08;主要实现分为 请求参数解密&#xff0c;返回参数加密 两个操作&#xff09; 玩一下&#xff0c; 开搞&#xff0c;开搞&#xff01;&#xff01;&#xff01; 目录 实现思路引入maven核心代码自定义注解AOP切面测试方法测试结果 实现思路 首先加解密用的…

Vue3(domdiff)最长递归子序列求解简易版(超简单)

Vue3&#xff08;domdiff&#xff09;最长递归子序列求解简易版 ⚠️ 关键词&#xff08;每一个都需要理解&#xff09;js 代码实现写完感想欢迎关注 ⚠️ 关键词&#xff08;每一个都需要理解&#xff09; 动态规划&#xff08;O(N^2)&#xff09;&#xff08;不提倡&#xf…

python接入AI 实现微信自动回复

import numpy as np # 引入numpy库&#xff0c;目的是将读取的数据转换为列表 import pandas as pd # 引入pandas库&#xff0c;用来读取csv数据 from uiautomation import WindowControl # 引入uiautomation库中的WindowControl类&#xff0c;用来进行图像识别和模拟操作 i…

windows组播发不出去解决办法

由于开启了虚拟网卡&#xff0c;安装VMWare虚拟化软件&#xff0c;可能会通过虚拟网卡发送组播&#xff0c;需要禁用虚拟化网卡。