之前我们的 Shiro 的会话状态跟踪是基于 Session 和 Cookie 的,客户端会保存服务端返回的 Shiro SessionID 到 Cookie,并且在请求时传输 Cookie 中会包含 SessionID 信息。
1.用请求头传递 SessionID
其实解决上述问题最简单的方式是客户端不再通过 Cookie 传递 SessionID,而是通过请求报文头传递。
1.1.获取 SessionID
服务端是通过 Shiro 的 DefaultWebSessionManager
获取请求的 SessionID 的:
public class DefaultWebSessionManager extends DefaultSessionManager implements WebSessionManager {
// ...
protected Serializable getSessionId(ServletRequest request, ServletResponse response) {
return getReferencedSessionId(request, response);
}
private Serializable getReferencedSessionId(ServletRequest request, ServletResponse response) {
// 从 Cookie 中获取 SessionID
String id = getSessionIdCookieValue(request, response);
if (id != null) {
// 如果获取到,设置 SessionID 来源为 Cookie
request.setAttribute(ShiroHttpServletRequest.REFERENCED_SESSION_ID_SOURCE,
ShiroHttpServletRequest.COOKIE_SESSION_ID_SOURCE);
} else {
// 没有获取到,尝试从 url 路径参数中获取 SessionID
id = getUriPathSegmentParamValue(request, ShiroHttpSession.DEFAULT_SESSION_ID_NAME);
if (id == null && request instanceof HttpServletRequest) {
// 没有获取到,尝试从 url 查询参数中获取
String name = getSessionIdName();
HttpServletRequest httpServletRequest = WebUtils.toHttp(request);
String queryString = httpServletRequest.getQueryString();
if (queryString != null && queryString.contains(name)) {
id = request.getParameter(name);
}
if (id == null && queryString != null && queryString.contains(name.toLowerCase())) {
// 没有获取到,尝试用小写的参数名获取
id = request.getParameter(name.toLowerCase());
}
}
if (id != null) {
//获取到,设置 SessionID 来源为 url
request.setAttribute(ShiroHttpServletRequest.REFERENCED_SESSION_ID_SOURCE,
ShiroHttpServletRequest.URL_SESSION_ID_SOURCE);
}
}
if (id != null) {
request.setAttribute(ShiroHttpServletRequest.REFERENCED_SESSION_ID, id);
// 如果已经获取到 SessionID,设置 SessionId 为有效的
request.setAttribute(ShiroHttpServletRequest.REFERENCED_SESSION_ID_IS_VALID, Boolean.TRUE);
}
// 设置 SessionId 是否重写
request.setAttribute(ShiroHttpServletRequest.SESSION_ID_URL_REWRITING_ENABLED, isSessionIdUrlRewritingEnabled());
return id;
}
}
我们可以仿照源码编写一个自定义SessionManager
,并且重写getSessionId
方法:
public class HeaderWebSessionManager extends DefaultWebSessionManager {
private static final String HEADER_SESSION_ID_SOURCE = "header";
private ShiroProperties shiroProperties;
/**
* 获取 SessionID
*
* @param request HTTP 请求
* @param response HTTP 响应
* @return SessionID
*/
protected Serializable getSessionId(ServletRequest request, ServletResponse response) {
// 尝试从请求报文头获取 SessionID
String sessionId = WebUtils.toHttp(request).getHeader(getSessionIdName());
if (sessionId != null) {
// 成功获取到 SessionID
// 设置 SessionID 来源
request.setAttribute(ShiroHttpServletRequest.REFERENCED_SESSION_ID_SOURCE,
HEADER_SESSION_ID_SOURCE);
// 设置 SessionID 为有效
request.setAttribute(ShiroHttpServletRequest.REFERENCED_SESSION_ID_IS_VALID, Boolean.TRUE);
// 设置 SessionID 重写
request.setAttribute(ShiroHttpServletRequest.SESSION_ID_URL_REWRITING_ENABLED, isSessionIdUrlRewritingEnabled());
return sessionId;
}
// 如果没有获取到,使用原有的从 Cookie 和 url 中获取逻辑
return super.getSessionId(request, response);
}
/**
* 获取 SessionID 的名称
*
* @return SessionID 名称
*/
private String getSessionIdName() {
return shiroProperties.getSessionIdHeaderName();
}
}
用于传输 SessionID 的请求头由配置文件 shiro.ynl 定义:
my
shiro
session-id-header-name SESSIONID
1.2.返回 SessionID
现在服务端已经可以从请求头中获取 SessionID 并利用这个 SessionID 从 Redis 获取 Session 对象。现在的问题是,如果客户端没有 SessionID 或 Session 已经失效,怎么将新的 SessionID 告知客户端。
这里我定义了一个 SpringMVC 的拦截器,用于在特定请求时在响应报文头中写入 SessionID:
public class SessionIdInterceptor implements HandlerInterceptor {
private ShiroProperties shiroProperties;
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 如果是 API 的请求,在返回报文头中设置 SessionID
String requestURI = request.getRequestURI();
if (requestURI.indexOf("/api/") == 0) {
String sessionIdHeaderName = shiroProperties.getSessionIdHeaderName();
Session session = SecurityUtils.getSubject().getSession();
response.setHeader(sessionIdHeaderName, session.getId().toString());
}
return true;
}
}
在这个示例项目中,假设所有的 json 格式请求都位于
/api/
路径下。
还需要在 SpringMVC 配置中注册这个拦截器:
public class SpringMvcConfig implements WebMvcConfigurer {
private SessionIdInterceptor sessionIdInterceptor;
// ...
public void addInterceptors(InterceptorRegistry registry) {
WebMvcConfigurer.super.addInterceptors(registry);
registry.addInterceptor(sessionIdInterceptor);
}
}
这样所有 /api/xxx
的请求的响应报文都会有一个响应头SESSIONID
,客户端只需要获取这个响应头并存储起来。每次请求时发送,并根据需要更新本地的SESSIONID
即可。
1.3.错误处理
现在还有一个遗留问题,Shiro 默认的过滤器对于错误处理是以转发到特定页面实现的,比如如果使用authc
过滤器检查请求是否登录,如果没有登录,就会将请求转发到登录页面。如果使用perms
过滤器检查是否有权限,如果没有权限,就会将请求转发到缺少权限的页面。
这里的登录页面和缺少权限页面,指 Shiro 过滤器中设置的相应路径。
如果是项目集成 JSP 的形式,这样做是没错的,但如果是前后端分离,客户端以 Restful 接口访问,就会导致服务端返回的响应信息中不是 json 格式的错误信息,而是对应的 Html 页面。
如果要解决这个问题,就需要使用自定义过滤器,并重写相应的错误处理逻辑。
下面是自定义的用于检查 API 请求是否登录的过滤器:
public class ApiAuthenticationFilter extends FormAuthenticationFilter {
/**
* 请求被拒绝时执行的处理
* @param request HTTP 请求
* @param response HTTP 响应
* @return 是否放行
* @throws Exception
*/
protected boolean onAccessDenied(ServletRequest request, ServletResponse response) throws Exception {
// 如果是 API 请求,返回 JSON 格式的报错信息
String requestURI = WebUtils.toHttp(request).getRequestURI();
if (requestURI.indexOf("/api/") == 0){
ApiResult<Object> apiResult = ApiResult.fail(ApiResult.ERROR_NEED_LOGIN, "请登录");
response.setCharacterEncoding("utf-8");
response.setContentType("application/json; charset=utf-8");
response.getWriter().write(JSON.toJSONString(apiResult));
return false;
}
return super.onAccessDenied(request, response);
}
}
在重写过滤器时我们只需要关心两个方法:
public abstract class AccessControlFilter extends PathMatchingFilter {
// 当前请求是否可以访问资源
protected abstract boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) throws Exception;
// 如果请求不被允许时的处理逻辑
protected abstract boolean onAccessDenied(ServletRequest request, ServletResponse response) throws Exception;
// ...
}
在上面这个示例中,authc
过滤器对应的类FormAuthenticationFilter
的isAccessAllowed
方法是通过 Session 检查是否登录,这与我们的需求不矛盾,当前系统虽然改用请求头传递 SessionID,但 Session 机制本身并没有修改,所以这只需要重写onAccessDenied
方法,在不允许访问(没有登录)时返回 JSON 格式的响应报文即可。
当然,需要在 Shiro 过滤器工厂中添加这个过滤器:
public ShiroFilterFactoryBean shiroFilterFactoryBean() {
ShiroFilterFactoryBean shiroFilterFactoryBean = new ShiroFilterFactoryBean();
// 设置过滤器关联的安全管理器
shiroFilterFactoryBean.setSecurityManager(webSecurityManager());
Map<String, Filter> filters = new HashMap<>();
// 添加自定义过滤器
filters.put("orRoles", new OrRolesAuthorizationFilter());
filters.put("onlineLimit", onlineLimitFilter());
filters.put("apiAuth", new ApiAuthenticationFilter());
// ...
}
现在配置文件 shiro.yml 中的/api/xxx
路径就可以使用apiAuth
这个过滤器进行登录检验:
my:
shiro:
urls:
- /api/user/login=anon
- /api/user/**=apiAuth
- /api/brand/**=apiAuth, orRoles[sys_manager,dep_manager], perms["brand:view"]
类似的,对于用于/api/xxx
请求的 Shiro 过滤器,都需要重新定义并返回 JSON 格式的错误信息,这里不再赘述。
2.JWT
的全称是 JSON Web Tokens(JSON 网络令牌),就像字面意思,它是一个 JSON 格式的,用于网络传输的令牌。
有一个 JWT 令牌的简单示例:
左侧是编码后的 JWT 令牌,右侧是解码后的 JWT 令牌内容。
令牌内容分为两部分:Header 和 Payload,Header 中保存加密方式等信息,Payload 中保存一般数据,比如常见的有:
-
sub(subject),签发令牌的主体
-
iat(issueAt),令牌签发时间
-
jti(JwtId),令牌id,一般使用 SessionID
-
exp(ExpiresAt),令牌的过期时间点
除了这些 JWT 预定义的内容,我们还可以在令牌中添加自定义内容。不过需要注意的是,令牌的所有内容都是以明码的方式传输的,如果要包含敏感信息,需要自行加密。JWT 令牌的用途并不是对内容加密,而是通过签名验证的方式确保内容不会在传输过程中被篡改。
和之前直接在请求头中使用 SessionID 相比,使用 JWT 包装 SessionID 的好处是可以附加信息以实现一些额外功能,比如为令牌设置单独的过期时间。但缺点是会增加系统的复杂度,实现难度较高。
下面我们看具体如何实现用 JWT 传输 SessionID。
JWT 是一种规范和标准,任何人都可以按照这套标准实现 JWT 令牌,有很多 JWT 的实现库可以使用:
这里使用 这个实现库。
添加依赖:
<dependency>
<groupId>com.auth0</groupId>
<artifactId>java-jwt</artifactId>
<version>4.4.0</version>
</dependency>
2.1.JwtService
根据这个库的 API 编写一个处理 JWT 编码和解码的服务类:
@Service
public class JwtServiceImpl implements JwtService {
private static final String CUSTOM_CLAIM_NAME = "custom-map-claim";
@Data
@AllArgsConstructor
public static class JwtInfo {
private String id;
private Map<String, Object> claim;
}
@Autowired
private JwtProperties jwtProperties;
private Algorithm algorithm;
@PostConstruct
public void init() {
// 设置 JWT 签名算法
algorithm = Algorithm.HMAC256(jwtProperties.getBase64EncodeSecretKey());
}
@Override
public String encodeToken(Map<String, Object> claim,
String subject,
@NonNull String sessionId,
long timeout) {
if (claim == null) {
claim = new HashMap<>();
}
if (ObjectUtils.isEmpty(subject)) {
subject = "system";
}
long currentTimeMillis = System.currentTimeMillis();
JWTCreator.Builder jwtBuilder = JWT.create()
.withClaim(CUSTOM_CLAIM_NAME, claim) // 附加信息
.withSubject(subject) // 设置签名人
.withJWTId(sessionId) // 将 SessionID 设置为 JWT 的id
.withIssuedAt(new Date(currentTimeMillis)); // 设置签名时间
if (timeout > 0) {
// 过期时间为当前时间 + 有效时长
long expireTimeMillis = currentTimeMillis + timeout;
jwtBuilder.withExpiresAt(new Date(expireTimeMillis));
}
// 用指定算法生成签名
return jwtBuilder.sign(algorithm);
}
@Override
public String encodeToken() {
Session session = SecurityUtils.getSubject().getSession();
String sessionId = session.getId().toString();
long tokenTimeout = jwtProperties.getTokenTimeout() * 1000;
if (tokenTimeout <= 0) {
tokenTimeout = session.getTimeout();
}
return encodeToken(null, null, sessionId, tokenTimeout);
}
@Override
public JwtInfo decodeToken(String jwtToken, boolean checkExpire) {
DecodedJWT jwt;
if (!checkExpire) {
long newExpire = (System.currentTimeMillis() + 5 * 60 * 1000) / 1000;
jwt = JWT.require(algorithm)
// 设置过期顺延时间,让 JWT token 在解析的时候不会因为过期解析失败
.acceptExpiresAt(newExpire)
.build()
.verify(jwtToken);
} else {
jwt = JWT.require(algorithm)
.build()
.verify(jwtToken);
}
Map<String, Object> claim = jwt.getClaim(CUSTOM_CLAIM_NAME).asMap();
String id = jwt.getId();
return new JwtInfo(id, claim);
}
@Override
public JwtInfo decodeToken(String jwtToken) {
return decodeToken(jwtToken, true);
}
@Override
public void verifyToken(String jwtToken) {
JWT.require(algorithm)
.build()
.verify(jwtToken);
}
}
这里用algorithm = Algorithm.HMAC256(jwtProperties.getBase64EncodeSecretKey());
指定签名算法,这个算法需要指定一个用于生成和验证签名的密钥。
这里通过配置文件指定:
my:
jwt:
secret-key: appple
jwt-header-name: JWT-TOKEN
token-timeout: 60 #令牌有效时长,单位秒。如果小于等于0,将使用 Session 剩余有效时长
对应的配置类:
@Configuration
@PropertySource(value = "classpath:/shiro.yml", factory = YamlPropertySourceFactory.class)
@ConfigurationProperties(prefix = "my.jwt")
@Data
public class JwtProperties {
/**
* 用于加密 JWT 的密钥
*/
@NotBlank
private String secretKey;
/**
* JWT 令牌使用的报文头名称
*/
@NotBlank
private String jwtHeaderName;
/**
* 令牌有效时长,单位秒
*/
@NotNull
private Long tokenTimeout;
public String getBase64EncodeSecretKey(){
return Base64.encodeToString(secretKey.getBytes(StandardCharsets.UTF_8));
}
}
这里提供一个getBase64EncodeSecretKey
方法返回 Base64 编码后的字符串,这样做是确保作为密钥使用的内容是 ASCII 字符,这样我们的原始密钥就可以是任意的 utf-8 字符串,包括中文。
我使用的这个 JWT 库解码时必须经过验证,这就出现一个问题,如果是令牌过期但 Session 没有过期的情况,按照正常处理逻辑,应该使用令牌中的 SessionID 重新签发令牌,并告知客户端。此时客户端可以使用新的令牌继续访问。但这里的问题在于此时令牌已经过期,解析令牌的时候会抛出异常,这样就无法解析令牌,也无法获取其中的 SessionID。
这里我想到的一个解决方式是通过在解析时,重新设置验证器的过期时间为当前时间5分钟后,也就是说令牌的过期时间将失效,这样就可以正常解析一个已经过期的令牌:
jwt = JWT.require(algorithm)
// 设置过期顺延时间,让 JWT token 在解析的时候不会因为过期解析失败
.acceptExpiresAt(newExpire)
.build()
.verify(jwtToken);
在方法参数中添加一个开关checkExpire
来控制是否使用这段代码。
为了方便使用,这里decodeToken
返回一个自定义的JwtInfo
类型,包含一个可以添加自定义附加信息的claim
属性和保存 SessionID 的id
属性。
2.2.JwtWebSessionManager
定义一个使用 JWT 传递 SessionID 的 SessionManager:
public class JwtWebSessionManager extends DefaultWebSessionManager {
private static final String JWT_SESSION_ID_SOURCE = "jwt";
@Autowired
private JwtProperties jwtProperties;
@Autowired
private JwtService jwtService;
@Override
protected Serializable getSessionId(ServletRequest request, ServletResponse response) {
// 尝试通过 JWT 令牌获取 SessionID
String jwtToken = WebUtils.toHttp(request).getHeader(jwtProperties.getJwtHeaderName());
if (!ObjectUtils.isEmpty(jwtToken)) {
// 解析并检查 JWT 令牌是否合法
JwtServiceImpl.JwtInfo jwtInfo;
try {
// 忽略令牌过期的问题,令牌过期检查由 Shiro 过滤器处理
jwtInfo = jwtService.decodeToken(jwtToken, false);
String sessionId = jwtInfo.getId();
this.setRequestAttributeAfterGetSessionId(request);
return sessionId;
} catch (JWTVerificationException e) {
// 令牌解析出错,视作 Session 过期
return super.getSessionId(request, response);
}
}
return super.getSessionId(request, response);
}
/**
* 成功获取到 SessionID 后设置请求的相关域属性
*
* @param request HTTP 请求
*/
private void setRequestAttributeAfterGetSessionId(ServletRequest request) {
request.setAttribute(ShiroHttpServletRequest.REFERENCED_SESSION_ID_SOURCE,
JWT_SESSION_ID_SOURCE);
request.setAttribute(ShiroHttpServletRequest.REFERENCED_SESSION_ID_IS_VALID, Boolean.TRUE);
request.setAttribute(ShiroHttpServletRequest.SESSION_ID_URL_REWRITING_ENABLED, isSessionIdUrlRewritingEnabled());
}
}
简单起见,在 SessionManager 中,解析令牌获取 SessionID 时不考虑令牌过期的问题,令牌过期的问题由 Shiro 过滤器处理。
2.3.JwtAuthenticationFilter
定义用 JWT 令牌访问接口的登录检查过滤器:
public class JwtAuthenticationFilter extends FormAuthenticationFilter {
private JwtService jwtService;
private JwtProperties jwtProperties;
public JwtAuthenticationFilter(JwtService jwtService, JwtProperties jwtProperties, SessionManager sessionManager) {
this.jwtService = jwtService;
this.jwtProperties = jwtProperties;
this.sessionManager = sessionManager;
}
private SessionManager sessionManager;
@Override
protected boolean isAccessAllowed(ServletRequest request,
ServletResponse response,
Object mappedValue) {
// 检查 JWT 令牌是否可以解析
String jwtToken = WebUtils.toHttp(request).getHeader(jwtProperties.getJwtHeaderName());
if (ObjectUtils.isEmpty(jwtToken)) {
// 缺少 JWT 令牌,不允许访问
return false;
}
JwtServiceImpl.JwtInfo jwtInfo;
try {
jwtInfo = jwtService.decodeToken(jwtToken);
} catch (TokenExpiredException tee) {
// token 过期
// 获取令牌中的 SessionID
jwtInfo = jwtService.decodeToken(jwtToken, false);
String sessionID = jwtInfo.getId();
if (ObjectUtils.isEmpty(sessionID)) {
// 令牌中缺少 SessionID,不允许访问
return false;
}
// 获取对应的 Session
Session session;
try {
session = sessionManager.getSession(new DefaultSessionKey(sessionID));
} catch (SessionException se) {
// Session 已经过期或无法获取,不允许访问
return false;
}
// Session 没有过期,重新生成令牌
// 令牌有效期使用配置中的设置
long timeout = jwtProperties.getTokenTimeout() * 1000;
if (timeout <= 0) {
// 如果配置中的设置小于等于0,使用 Session 剩余时长作为令牌有效时长
timeout = session.getTimeout();
}
jwtToken = jwtService.encodeToken(null, null, sessionID, timeout);
WebUtils.toHttp(response).setHeader(jwtProperties.getJwtHeaderName(), jwtToken);
return false;
} catch (JWTVerificationException jve) {
// 其它令牌解析错误
// 不允许访问
return false;
}
// 使用原有的身份验证逻辑检查是否是登录状态
return super.isAccessAllowed(request, response, mappedValue);
}
@Override
protected boolean onAccessDenied(ServletRequest request, ServletResponse response) throws Exception {
// 不允许访问时,让用户去登录
response.setCharacterEncoding("utf-8");
response.setContentType("application/json; charset=utf-8");
ApiResult<Object> apiResult = ApiResult.fail(ApiResult.ERROR_NEED_LOGIN, "需要登录");
response.getWriter().write(JSON.toJSONString(apiResult));
return false;
}
}
这里比较棘手的是令牌过期问题,如果令牌和 Session 都过期了,那用户只能重新登录。但更常见的应该是令牌过期但 Session 没有过期。毕竟令牌不能像 Session 一样,每次客户端请求时都刷新过期时间。我这里是添加了一个处理逻辑,如果令牌过期但 Session 没有过期,就重新使用这个 SessionID 生成新的令牌,并在响应报文头中返回新的令牌。同时,因为访问被拒绝,onAccessDenied
方法中会返回需要登录等相关信息。
此时客户端可以检查返回的 Header 中是否有新的令牌,如果有,可以替换本地令牌并尝试重新访问,如果没有,就只能让用户重新登录。
这里的处理并不完美,更好的处理方式可能是在令牌过期但 Session 没过期时生成新的令牌,并且用新的令牌创建一个 Request 对象,将请求转发到这个 Request 对象,这样做的好处是,虽然本次请求客户端传递的令牌已经过期,但依然可以正常返回数据,以及新的令牌,客户端只需要更新令牌即可,不需要重新发送请求。当然,这只是一个想法,这里我并没有实现。
2.4.配置类
最后,在配置类中添加过滤器和 WebSessionManager:
@Bean
public ShiroFilterFactoryBean shiroFilterFactoryBean(JwtService jwtService, JwtProperties jwtProperties) {
ShiroFilterFactoryBean shiroFilterFactoryBean = new ShiroFilterFactoryBean();
// 设置过滤器关联的安全管理器
shiroFilterFactoryBean.setSecurityManager(webSecurityManager());
Map<String, Filter> filters = new HashMap<>();
// 添加自定义过滤器
filters.put("orRoles", new OrRolesAuthorizationFilter());
filters.put("onlineLimit", new OnlineLimitFilter(objectRedisTemplate, webSessionManager(), redisSessionDao(), shiroProperties));
filters.put("apiAuth", new ApiAuthenticationFilter());
filters.put("jwtAuthc", new JwtAuthenticationFilter(jwtService, jwtProperties, webSessionManager()));
shiroFilterFactoryBean.setFilters(filters);
// ...
}
注意,Shiro 过滤器不能定义为 Spring Bean,否则会被 Shiro 认为是全局过滤器,对于任何路径都会使用该过滤器(无视过滤器链的匹配规则),这样会导致一些莫名其妙的 Bug。
使用 JwtWebSessionManager 作为 SessionManager:
@Bean
public DefaultWebSessionManager webSessionManager() {
DefaultWebSessionManager webSessionManager = new JwtWebSessionManager();
// ...
}
在配置文件中的过滤器链上可以使用新的过滤器:
my:
shiro:
urls:
- /api/user/login=anon
- /api/user/**=jwtAuthc
- /api/brand/**=jwtAuthc, orRoles[sys_manager,dep_manager], perms["brand:view"]
现在通过/api/user/login
登录成功后,可以从响应头获取 JWT 令牌,然后每次请求都需要在请求头带上 JWT 令牌进行访问。如果令牌过期,服务端会返回需要登录的错误信息,此时客户端可以检查响应头中是否携带新的令牌,如果有,就更新本地令牌并重新访问,如果没有,就让用户进行登录以获取新令牌。
最后需要说明的是,令牌的有效时长必须设置为小于 Session 的有效时长。否则会出现令牌没有过期,但 Session 已经过期的情况,而用户的登录信息是保存在 Session 中的,这样会导致 Bug。当然,最简单的方式是将 Session 和令牌有效时长都设置一个比较长的时间,但这样会导致系统安全性降低,以及 Redis 上存在一些长期没有使用的 Session 占用空间的问题。
The End,谢谢阅读。
本文的完整示例可以从获取。
文章评论