本文是我在前后端分离项目中使用Shiro遇到的问题和解决方法的汇总总结,包括对各种情况的分析和思考。
本文代码下载:gitee
现在我们要考虑的就是如何实现登录了,我的想法有两种:
万变不离其宗,登录功能的实现大多是由二者变化而来。第一种代表使用服务器来管理用户信息,第二种则是由客户端来保存,二者我都会一一实现,并讲解我遇到的问题。
以下代码框架为Springboot,Shiro版本为1.7.1。为了省事、代码部分我只会写Shiro相关,不会去完成整个流程
这是最常用也最简单的实现方法,定义流程如下:
AuthorizingRealm
用来认证和授权示例代码如下,实现一个非常简单的认证模块
*******************************************ShiroConfig.java**********************************************
@Configuration
public class ShiroConfig {
@Bean
public Realm defaultRealm(){
return new AuthorizingRealm() {
@Override
protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
SimpleAuthorizationInfo authorizationInfo = new SimpleAuthorizationInfo();
authorizationInfo.addStringPermission("*");
return authorizationInfo;
}
@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token)
throws AuthenticationException {
return new SimpleAuthenticationInfo("admin","123456",this.getClass().getName());
}
};
}
@Bean
public ShiroFilterChainDefinition shiroFilterChainDefinition(){
DefaultShiroFilterChainDefinition shiroFilterChainDefinition = new
DefaultShiroFilterChainDefinition();
shiroFilterChainDefinition.addPathDefinition("/login","anon");
shiroFilterChainDefinition.addPathDefinition("/shiro/**","anon");
shiroFilterChainDefinition.addPathDefinition("/**","user");
return shiroFilterChainDefinition;
}
}
*****************************************ShiroController.java********************************************
@RestController
@RequestMapping("shiro")
public class ShiroController {
@RequestMapping("login")
public Object login(){
return "请登录";
}
}
yml文件配置
shiro:
loginUrl: /shiro/login
相比不分离的项目,你要做的就是将loginUrl
的值改为一个返回数据的接口url,并在你的前端进行判断。
但是!在跨域环境下你会发现Cookie压根就传输不到服务端,这也导致你的用户每次都需要认证。解决这个问题的方法如下:
Set-Cookie
的SameSite
为None
,并设置Secure
选项。设置Secure的必要条件是你的请求是HTTPS
实现第2条的方式取决于使用的Session类型,Shiro中有两种Session类型:
挂载Session修改SetCookie:
server:
servlet:
session:
cookie:
secure: true
@Bean
public TomcatContextCustomizer sameSiteCookiesConfig() {
return context -> {
final Rfc6265CookieProcessor cookieProcessor = new Rfc6265CookieProcessor();
// 设置Cookie的SameSite
cookieProcessor.setSameSiteCookies(SameSiteCookies.NONE.getValue());
context.setCookieProcessor(cookieProcessor);
};
}
本地Session修改Set-Cookie:
shiro:
sessionManager:
cookie:
secure: true
same-site: None
如果你实在不想用HTTPS,或是项目的环境用不到HTTPS。那么除了代理服务器外,还可以实现类似Cookie-Session的流程,但不使用Cookie,使用自己定义的一个标识。
要做到这一步,首先要明白Shiro内Session的大致流程和原理:
思考一下我们现在要做什么:
SessionManager
来进行获取和创建的操作Session
持久化或在内存中保存的工具,Shiro管它叫SessionDAO
Shiro已经做过这些了,我们只需要修改其中的一小部分细节,将原本用Cookie获取Session的逻辑改为我们使用请求头(SESSION-TOKEN)获取,并提供一个存储器SessionDAO
。那么我们需要做的是:
SessionManager
SessionDAO
(可以使用Shiro自带的DAO,但它们都被标为不推荐,原因是它们使用的是JVM
的内存,而且并没有持久化)1. 实现一个SessionManager
public class TokenWebSessionManager extends DefaultWebSessionManager {
private static final String SESSION_ID_TOKEN_NAME = "SESSION-TOKEN";
/**
* 获取SessionId,在获取Session时调用,你可以认为这是一个键值对 SessionId => Session
* 获取到SessionId也就代表你获取到Session了
* @param request 请求
* @param response 响应
* @return
*/
protected Serializable getSessionId(ServletRequest request, ServletResponse response) {
//从请求头中获取SESSION-TOKEN并放置到request attribute中
Serializable id = ((HttpServletRequest) request).getHeader(SESSION_ID_TOKEN_NAME);
request.setAttribute(SESSION_ID_TOKEN_NAME,id);
return id;
}
/**
* Session生成后的通知方法,在这一步我们添加SessionId到请求中去
* 以免我们在Controller的方法中获取不到
* @param session 生成后的Session
* @param context Session的上下文,包含Subject,用户信息等
*/
@Override
protected void onStart(Session session, SessionContext context) {
//判断是否是一个http的上下文
if (!WebUtils.isHttp(context)) {
return;
}
//将生成的SESSION-TOKEN放入到request attribute中,你可以在Controller返回它
HttpServletRequest request = WebUtils.getHttpRequest(context);
request.setAttribute(SESSION_ID_TOKEN_NAME,session.getId().toString());
}
}
2. 实现一个SessionDAO,Shiro的support中有支持EhCache,为了省事,我们直接使用。你也可以使用Redis和其他缓存框架来实现一个自己的Cache
引入EhCache的依赖
<dependency>
<groupId>org.apache.shirogroupId>
<artifactId>shiro-ehcacheartifactId>
<version>1.7.1version>
dependency>
实现EhCacheSessionDAO
public class EhCacheSessionDAO extends CachingSessionDAO {
/**
* 更改前的通知
* @param session
*/
@Override
protected void doUpdate(Session session) {
}
/**
* 删除后的通知
* @param session
*/
@Override
protected void doDelete(Session session) {
}
/**
* 加入缓存前的通知
* @param session
*/
@Override
protected Serializable doCreate(Session session) {
Serializable sessionId = generateSessionId(session);
assignSessionId(session, sessionId);
return sessionId;
}
/**
* 找不到缓存调用,基本没用
* @param sessionId
* @return
*/
@Override
protected Session doReadSession(Serializable sessionId) {
return null;
}
}
3. 注册到Spring,将EhCache-support的EhCacheManager也加入到Spring
@Bean
public SessionManager sessionManager(SessionDAO sessionDAO
, Environment environment, SessionFactory sessionFactory, Cookie sessionCookieTemplate){
TokenWebSessionManager webSessionManager = new TokenWebSessionManager();
webSessionManager.setSessionIdCookieEnabled(environment.getProperty(
"shiro.sessionManager.sessionIdCookieEnabled",boolean.class,false));
webSessionManager.setSessionIdUrlRewritingEnabled(environment.getProperty(
"shiro.sessionManager.sessionIdUrlRewritingEnabled",boolean.class,false));
webSessionManager.setSessionIdCookie(sessionCookieTemplate);
webSessionManager.setSessionFactory(sessionFactory);
webSessionManager.setSessionDAO(sessionDAO);
webSessionManager.setDeleteInvalidSessions(environment.getProperty(
"shiro.sessionManager.deleteInvalidSessions",boolean.class,true));
return webSessionManager;
}
@Bean
public CacheManager cacheManager(){
return new EhCacheManager();
}
@Bean
public SessionDAO sessionDAO(){
return new EhCacheSessionDAO();
}
如此我们便将Cookie=>Session
改变成SESSION-TOKEN=>Session
,现在我们可以在登录后获取请求内的SESSION-TOKEN并将其返回给前端
@PostMapping("login")
public Object login(HttpServletRequest request){
SecurityUtils.getSubject().login(new UsernamePasswordToken("user","123456"));
return request.getAttribute(TokenWebSessionManager.SESSION_ID_TOKEN_NAME);
}
如此,使用服务端管理登录状态的实现就完成了。使用服务端的状态管理可实现更加细腻的用户操作,如登录用户列表查看,踢出登录状态等。
相比之下,使用Token+JWT在简单应用下更有优势,接下来看看我们怎么使用Shiro来实现这一操作。
1.定义两个Realm
添加一个BearerShiroConfig,用来定义bearer方式登录的配置
@Bean
public Realm defaultRealm(){
return new AuthorizingRealm() {
@Override
protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
SimpleAuthorizationInfo authorizationInfo = new SimpleAuthorizationInfo();
authorizationInfo.addStringPermission("*");
return authorizationInfo;
}
@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {
return new SimpleAuthenticationInfo("admin","123456",this.getClass().getName());
}
};
}
@Bean
public Realm bearerRealm(){
AuthorizingRealm realm = new AuthorizingRealm() {
@Override
protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
SimpleAuthorizationInfo authorizationInfo = new SimpleAuthorizationInfo();
authorizationInfo.addStringPermission("*");
return authorizationInfo;
}
@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {
//转换
BearerToken bearerToken = (BearerToken) token;
//前端添加到请求头上的token
String bearer = bearerToken.getToken();
//处理token,使用JWT的话直接解密就好
ObjectMapper objectMapper = new ObjectMapper();
try {
Map<String,String> py = objectMapper.readValue(bearer, HashMap.class);
//返回认证信息后会和token的credentials比对一下,所以我们直接返回token的credentials
return new SimpleAuthenticationInfo(py.get("user"),token.getCredentials(),this.getClass().getName());
} catch (JsonProcessingException e) {
throw new AuthenticationException("token解析失败");
}
}
};
realm.setAuthenticationTokenClass(BearerToken.class);
return realm;
}
2.定义过滤器链
还是在上一步创建的BearerShiroConfig中添加:
@Bean
public ShiroFilterChainDefinition shiroFilterChainDefinition(){
DefaultShiroFilterChainDefinition shiroFilterChainDefinition = new DefaultShiroFilterChainDefinition();
shiroFilterChainDefinition.addPathDefinition("/jwt/login","anon");
shiroFilterChainDefinition.addPathDefinition("/shiro/**","anon");
shiroFilterChainDefinition.addPathDefinition("/**","authcBearer");
return shiroFilterChainDefinition;
}
以上就完成了Shiro认证的所有操作,只需在登录成功后返回JWT串:
/**
* jwt登录,没有报错则登录成功,返回JWT加密后的串
* @param request
* @return
*/
@PostMapping("jwt/login")
public Object jwtLogin(HttpServletRequest request){
SecurityUtils.getSubject().login(new UsernamePasswordToken("user","123456"));
Map<String,String> py = new HashMap<>();
py.put("user","admin");
return JWTUtils.getToken(py);
}
前端请求接口时不要忘记在请求头上加上 Authorization:Bearer JWT串
。例子:
Authorization:Bearer eyJhbGciOiJIUzUxMiJ9.eyJleHAiOjE1NjE4Njk2MjMsInVzZXIiOiIxMjM0NSIsInN1YiI6InhsIn0.5szo9Rfp_6b3cszas3-g0V719IIWAN97ZIZhx49-CK1mLGJlbYd__idPuT1EEsMSq92FV7vneEo9IrrZw9XH6g
本文演示了使用服务端保存登录状态、客户端保存登录状态的两种情形,并使用Shiro将其实现。在前后端分离情况下,二者都各有优劣。
我为什么要写这篇文章,原因是我在前后端分离时就遇到过上述的问题。开始我使用Cookie,发现在http环境的跨域不能携带Cookie,于是我转为使用JWT来进行传输。此次没有出现登录失败的情况,但每次访问接口都需要访问数据库获取权限和其他内容,正确的操作是进行缓存,但重新写一个用户的缓存管理不如使用现成的。所以我结合二者,使用自定义的请求头携带标识,获取Session。