SpringBoot集成Shiro

这是我参与11月更文挑战的第4天,活动详情查看:2021最后一次更文挑战

Shiro简介

Apache Shiro是一个强大且易用的Java安全框架,我们可以通过shiro完成:认证、授权、加密、会话管理、与 Web 集成、缓存等。这些简直不要太嗨,一个人就可以肝这么多事情,堪称肝王。而且shiro是Apache下的项目,给人一种很靠谱的赶脚,并且它不跟任何框架或容器绑定。

集成shiro步骤

我们先来看一下如何集成shiro吧。

这里需要提醒一下工作经验不多的小司机,遇到不懂的技术切勿抓破头皮读概念,有时候亲自动手实现一遍代码,等到代码运行一遍,看完结果之后,再回过头来,重新看概念,捋一捋代码实现,会有不同的感受,如果在学习上还是感觉很难学下去,可以在评论区发出来,社区有很多大神可以解答,切勿闭门造车。

image.png

  • 配置核心安全事务管理器
@Bean
public SecurityManager securityManager() {
    /** 1. 引入两种身份验证realm */
    /**loginRealm用于登录认证,customRealm用于其他接口认证 */
    securityManager.setRealms(Lists.newArrayList(customRealm(), loginRealm()));
​
     DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();
    /** 2.设置认证策略*/
    securityManager.setAuthenticator(authenticator());
    
    /** 3.关闭shiro自带的session。让每次请求都得到过滤*/
    DefaultSubjectDAO subjectDAO = new DefaultSubjectDAO();
    subjectDAO.setSessionStorageEvaluator(sessionStorageEvaluator());
    securityManager.setSubjectDAO(subjectDAO);
    return securityManager;
}
复制代码

先来看 【第一步】 ,我们引入了两种身份验证(loginRealmcustomRealm),令人头大,这个Realm到底是啥呢,求求花Gie说说人话吧。

image.png

简单的说,后台会接收各种场景发来的请求,比如登录、注册、新增用户等等,我们不可能一棒子打死,全部用一种策略来校验所有请求,比如登录请求需要调用数据库查询登录信息是否正确,而一旦登录成功后,此后再发送其他接口请求,每次再连接数据库查询用户登录信息是否正确,显然是不合适的,所以需要其他策略。

image.png

因此,使用两种Realm,其中用于处理登录验证的loginRealm,该realm只处理鉴权登录请求,校验登录是否正确;而对于其他所有的接口请求,都会被customRealm进行拦截处理。

接下来我们看 【第二步】setAuthenticator()方法是用来设置认证策略,那什么是认证策略呢,这听起来也不怎么像人话,但其实比较好理解,先来看一下下面这段代码:

/**
 *多个Realm时,设置认证策略
*/
@Bean
public ModularRealmAuthenticator authenticator() {
    ModularRealmAuthenticator authenticator = new MultiRealmAuthenticator();
    // 多个Realm的认证策略,默认 AtLeastOneSuccessfulStrategy
    AuthenticationStrategy strategy = new FirstSuccessfulStrategy();
    authenticator.setAuthenticationStrategy(strategy);
    return authenticator;
}
复制代码

是不是一脸懵逼,不要急,这里举个具体场景:我们两个用于认证的Realm,默认情况下,我们接口每次请求都会被这两个Realm分别校验,那如果有一个校验成功,另一个校验失败,那这次请求到底成功还是失败呢,这时候就需要一种策略来规范,官方给出三种策略:

  • FirstSuccessfulStrategy:只要有一个Realm验证成功即可,只返回第一个Realm身份验证成功的认证信息,其他的忽略;(注:这里"第一个"指的是认证成功得那一个realm)
  • AtLeastOneSuccessfulStrategy:只要有一个Realm验证成功即可,和FirstSuccessfulSTRATEGY不同,将返回所有Realm身份验证成功的认证信息;
  • AllSuccessfulStrategy:所有Realm验证成功过才算成功,且返回所有Realm身份验证的认证信息,如果有一个失败就失败。

最后看一下 【第三步】 ,这里我们禁用session, 不保存用户登录状态,保证每次请求都重新认证。

/**
* 禁用session, 不保存用户登录状态。保证每次请求都重新认证
*/
@Bean
protected SessionStorageEvaluator sessionStorageEvaluator() {
    DefaultSessionStorageEvaluator sessionStorageEvaluator = new DefaultSessionStorageEvaluator();
    sessionStorageEvaluator.setSessionStorageEnabled(false);
    return sessionStorageEvaluator;
}
复制代码
  • 自定义LoginRealm

LoginRealm 只负责处理登录请求,它的实现逻辑就是获取请求接口中账号密码,通过数据库查询,并将查询结果保存到redis,供后续接口请求时验证。

/**
* 查询数据库,将获取到的用户安全数据封装返回(伪代码)
*/
@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws AuthenticationException {
    LoginToken token = (LoginToken) authenticationToken;
    String username = token.getUsername();
    String password = new String((char[]) authenticationToken.getCredentials());
​
    //查询是否存在当前用户,有效用户查询结果只能为1
    SysUser user = loginService.getUserByNameAndPwd(username, password);
    if(user == null){
        throw new AuthException(LOGIN_PWD_ERROR.getCode(), "登录验证失败, 用户名或密码错误");
    }
    // 缓存当前登录用户信息
    redisUtil.setex(key, value);
    return new SimpleAuthenticationInfo(jwtEntity, password, getName());
}
复制代码
  • 自定义CustomRealm

上面的LoginReaml会将登陆成功的用户信息缓存,此后接口每次调用后台时,都会在header请求头中携带token字段,用于鉴权,而鉴权就是在CustomRealm中进行,伪代码如下:

@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws AuthenticationException {
    //1.获取请求token
    CustomerToken jwtToken = (CustomerToken) authenticationToken;
    
    ....省略
    //2.从jwt中解析出用户名、密码
    String username = "";
​
    //3.取出redis中用户信息
    String jwtCache = redisUtil.get(StringUtils.joinWith(SYMBOL_UNDERLINE, REDIS_KEY_PREFIX_LOGIN_ACCOUNT, username), 0);
    
    //4.对缓存信息进行处理
    if (jwtCache == null) {
        log.warn("[获取密码缓存失败, 查询数据库 account = {}]", username);
    } else {
        log.error("[当前账号在其他地方登录, account = {}]", username);
    } else if (!StringUtils.equals(password, cacheJwtObj.getPassword())) {
        log.error("缓存密码校验失败");
    }
}
return new SimpleAuthenticationInfo(jwtObject, jwtToken.getCredentials(), getName());
}
​
复制代码
  • 配置拦截内容

有些请求是不需要被拦截的,像注册这类接口及一些静态资源(css、图片、js等),这时我们就需要在shiro中提前设置,示例代码如下:

ShiroFilterFactoryBean shiroFilterFactoryBean = new ShiroFilterFactoryBean();
shiroFilterFactoryBean.setSecurityManager(securityManager);
//登录地址
shiroFilterFactoryBean.setLoginUrl("/login");
//登录成功后要跳转的连接
shiroFilterFactoryBean.setSuccessUrl("/authorized");
//未授权跳转地址
shiroFilterFactoryBean.setUnauthorizedUrl("/403");
​
//增加自定义过滤器(JwtFilter)
Map<String, Filter> filterMap = Maps.newHashMapWithExpectedSize(1);
filterMap.put(JwtFilter.class.getName(), new JwtFilter());
shiroFilterFactoryBean.setFilters(filterMap);
​
//配置资源访问权限(anon 表示资源都可以匿名访问)
LinkedHashMap<String, String> filterChainDefinitionMap = new LinkedHashMap<>();
filterChainDefinitionMap.put("/css/**", "anon");
filterChainDefinitionMap.put("/js/**", "anon");
filterChainDefinitionMap.put("/login", "anon");
filterChainDefinitionMap.put("/**", JwtFilter.class.getName());
shiroFilterFactoryBean.setFilterChainDefinitionMap(filterChainDefinitionMap);
复制代码
  • 登录测试

这里我们获取到前端传入的用户名、密码,封装到LoginToken中调用subject.login()方法,这时就会触发LoginRealm进行鉴权,为什么会触发LoginRealm而不是CustomRealm,我们上面已经详细介绍了。

LoginToken loginToken = new LoginToken(username,password);
Subject subject = SecurityUtils.getSubject();
//登录操作(前往LoginRealm鉴权)
subject.login(usernamePasswordToken);
//返回前端token信息
JwtEntity jwtEntity = (JwtEntity) subject.getPrincipal();
return new SysUserLoginDto(JwtUtils.createJwtToken(JSON.toJSONString(jwtEntity)), jwtEntity.getLoginId());
复制代码

测试结果,可以拿到 jwt生成的token值,后续再次请求时,需要携带该token进行鉴权:

{"token":"eyJhbGciOiJIUzI1NiJ9.eyJqd3RLZXkiOiJ7XCJhY2NvdW50XCI6XCIxXCIsXCJsb2dpbklkXCI6MSxcInBhc3N3b3JkXCI6XCIweEMwbWZJVGR4MjJ3ejFKMVU1c3pnPT1cIixcInNlc3Npb25JZFwiOlwiODg2MjcyNDkyOTE0NjA2MTJcIn0iLCJleHAiOjE2MjY5MjI5MDJ9.VF8R9X3hZb6SDvShZbRdSRgwaAUUE7dC7XQhIuWBSn4","loginId":1}
复制代码