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
*/
@Override
public Object getPrincipal() {
return userName;
}
public String getJwt() {
//获取jwt
return jwt;
}
/**
* 只用在登录中获取提交的凭证
*
* @return
*/
@Override
public 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 { @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); } } }
-
执行认证
因为需要禁用掉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
提供了三个请求
-
登录。 在这里生成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 {
@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.