1. Shiro框架回顾
到目前为之,Shiro框架本身的知识点已经介绍完了。web环境下,整个框架从使用的角度我们需要关注的几个点:
-
要使用Shiro框架,就要创建核心部件
securityManager
对象。 SpringBoot项目中,引入shiro-spring-boot-web-starter
组件后,其自动配置会自动创建securityManager
对象,默认创建的是org.apache.shiro.web.mgt.DefaultWebSecurityManager
, 一般项目web项目使用它已经够用,如果有特殊要求我们自己可以定制 -
securityManager
对象要能够正常工作,我们需要为它准备:-
subjectDAO
一般情况下,我们无需配置这个对象,默认使用的是DefaultSubjectDAO
, 看名字好像是Subject对象的存储,其实Subject对象每次访问都会创建一个新的subject对象,并会绑定到当前线程中。其实它所做的事情并不是存储Subject,而是将当前Subject中的身份信息:principal
和authenticated
是否校验成功放入到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作为缓存
-
-
realm
配置realm的时候关注是否开启缓存,缓存的key是什么 -
sessionManager
为他配置sessionDAO
. 前面我们自定义了sessionDAO,将session数据保存到了redis中。 -
过滤器相关
shiroFilterFactoryBean
实际类型为ShiroFilterFactoryBean
, 它的作用就是创建了一个Filter,并将这个Filter注册到Servlet容器中。同时它也管理着系统默认的Filter和自定义的Filter。 它需要:securityManager
filterChainDefinitionMap
: 即URL与过滤器名称之间的映射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" }
- 标准(Standard) 或 公开(Public) claim:
-
Signature(签名): 用于验证JWT完整性和防篡改,通过对Header和Payload进行签名来保证其内容未被更改。Signature的计算过程基于Header中指定的加密算法.Signature本身不是一个JSON对象,而是通过加密算法生成的一个字节序列,经过Base64URL编码后作为JWT的第三个部分。
示例:
HmacSHA256(base64UrlEncode(header) + "." + base64UrlEncode(payload), secret)
3.3 认证步骤
- 登录成功生成Token,发送给客户端。 生成的时候根据实际场景需要自定义 Payload内容
- 客户端放入到请求头
Authorization-Token
中。 客户端可以解析Payload内容(将base64格式的 payload还原成字符串即可) - 在需要身份的URL上携带请求头
Authorization-Token
发送到服务端 - 服务端请求到来时,先被中间件进行拦截,获取到
Authorization-Token
请求头中的token信息后,解析这个token,解析其实就是取出 Header部分和Payload部分,然后用秘钥进行SHA256进行签名,将签名的结果和Token中的Signature进行比较,一致则签名通过,放行到Controller进行处理,不一致则认证失败,直接给出认证结果响应到客户端。
Token失效有两种方案:
- 设置Token过期时间,如果过期,则认证不能通过。
- 为每个用户设置一个秘钥,并与用户一起保存到数据库或缓存中。如果需要让用户重新登录,比如退出登录或者修改密码或者重置密码后,直接更改数据库中的秘钥,即重新生成一个秘钥。这样 在验证token的时候,由于秘钥发生变化,那么验签的结果就会与原来不同,从而判定token失效
4. Shiro使用JWT的必要性
-
无状态服务需求
每个服务实例应能独立处理请求,不依赖于共享的服务器端会话状态。JWT提供了这种能力,它将用户的认证信息以加密的JSON对象形式存储在客户端(通常是浏览器的Cookie或HTTP头部),每次请求时携带此Token。Shiro与JWT整合后,可以基于Token直接进行身份验证,无需在服务器端维护会话,从而适应分布式、水平扩展的场景。
-
跨域支持
如果应用涉及多个子域、前端与后端分离,或者需要与其他第三方系统进行安全交互,JWT的跨域特性非常有用。由于Token包含所有必要的认证信息,可以在不同域名或接口之间自由传递,客户端只需在请求时添加Token即可,不受同源策略限制。整合Shiro与JWT,可以使系统轻松支持跨域认证与授权。
-
移动端友好
在移动应用(如原生App或React Native、Flutter等跨平台应用)中,维持会话状态可能较为复杂。JWT因其无状态特性,特别适合移动端应用,客户端只需妥善保管Token并在每次请求时发送,避免了复杂的会话同步问题。与Shiro整合,可为移动应用提供一致且高效的认证授权机制。
-
长会话与单点登录(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 过滤器
在过滤器要完成的功能:
-
预处理
通过这个过滤器的请求支持跨域请求。在JWT的场景下,不需要创建session,而其它场景下又需要创建sesssion。所以是否创建session是根据场景条件来决定。所以向reqeust attribute中放一个是否需要创建session的标识,后面我们自定义的
SessionStorageEvaluator
会读取这个标识,根据条件来决定是否创建session -
跨域处理
因为添加了自定义的非标准的请求头,浏览器会认为是非简单请求,此时浏览器就会发送一个
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);}...
}
-
创建
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);}} }
-
执行认证
因为需要禁用掉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
提供了三个请求
-
登录。 在这里生成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;} }
-
退出登录
@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发送过来验证的时候,不能通过验证。
-
首页授权访问
@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.