在前后端分离项目中使用Shiro

前言

本文是我在前后端分离项目中使用Shiro遇到的问题和解决方法的汇总总结,包括对各种情况的分析和思考。

本文代码下载:gitee

思考

现在我们要考虑的就是如何实现登录了,我的想法有两种:

  1. 使用Cookie+Session来保存登录状态
  2. 使用Token+JWT来保存状态

万变不离其宗,登录功能的实现大多是由二者变化而来。第一种代表使用服务器来管理用户信息,第二种则是由客户端来保存,二者我都会一一实现,并讲解我遇到的问题。

以下代码框架为Springboot,Shiro版本为1.7.1。为了省事、代码部分我只会写Shiro相关,不会去完成整个流程

使用Cookie+Session来保存登录状态

这是最常用也最简单的实现方法,定义流程如下:

  1. 定义一个AuthorizingRealm用来认证和授权
  2. 设置过滤器链、使Shiro能捕获需要认证的接口
  3. 注册以上两个步骤产生的对象到Spring中,使Shiro能获取到它们
  4. 设置未登录跳转的url

示例代码如下,实现一个非常简单的认证模块

*******************************************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压根就传输不到服务端,这也导致你的用户每次都需要认证。解决这个问题的方法如下:

  1. 使用代理服务器将前后端挂在同一个域下
  2. 设置Set-CookieSameSiteNone,并设置Secure选项。设置Secure的必要条件是你的请求是HTTPS

实现第2条的方式取决于使用的Session类型,Shiro中有两种Session类型:

  • 挂载Session,将认证的信息存储于容器(Tomcat)的Session中
  • 本地Session,实现自己的Session存储,Session的获取也依赖于Cookie

挂载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的大致流程和原理:
在前后端分离项目中使用Shiro_第1张图片
思考一下我们现在要做什么:

  • 一个SessionManager来进行获取和创建的操作
  • 一个能够将Session持久化或在内存中保存的工具,Shiro管它叫SessionDAO

Shiro已经做过这些了,我们只需要修改其中的一小部分细节,将原本用Cookie获取Session的逻辑改为我们使用请求头(SESSION-TOKEN)获取,并提供一个存储器SessionDAO。那么我们需要做的是:

  1. 实现自己的SessionManager
  2. 实现自己的SessionDAO(可以使用Shiro自带的DAO,但它们都被标为不推荐,原因是它们使用的是JVM的内存,而且并没有持久化)
  3. 将以上两个注册到Spring,使得Shiro能正确引用它们

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来保存状态

相比之下,使用Token+JWT在简单应用下更有优势,接下来看看我们怎么使用Shiro来实现这一操作。

  1. 定义两个Realm,一个接收用户名密码来进行验证登录;一个用来解析JWT,提供登录状态
  2. 定义过滤器链,使用authcBearer拦截验证

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。

你可能感兴趣的:(shiro,java,spring,boot,spring)