红茶的个人站点

  • 首页
  • 专栏
  • 开发工具
  • 其它
  • 隐私政策
Awalon
Talk is cheap,show me the code.
  1. 首页
  2. 其它
  3. 正文

Shiro 学习笔记3:Spring Boot 集成

2023年9月23日 992点热度 0人点赞 0条评论

1.准备工作

创建一个 SpringBoot 应用,将打包方式改为 war,并添加 JSP 的相关支持。

Spring Boot 整合 JSP 的相关内容可以阅读这篇文章。

2.整合

2.1.依赖

<dependency>
    <groupId>org.apache.shiro</groupId>
    <artifactId>shiro-spring-boot-web-starter</artifactId>
    <version>1.12.0</version>
</dependency>

2.2.Realm

同样的,需要实现 Realm:

public class MyRealm extends AuthorizingRealm {
    @Autowired
    private UserService userService;
​
    @PostConstruct
    public void initMyRealm(){
        // 设置密码匹配器
        // 设置密码匹配方式为 SHA1 加密后匹配
        HashedCredentialsMatcher matcher = new HashedCredentialsMatcher(EncryptorUtil.ALGORITHM_NAME);
        // 设置加密次数
        matcher.setHashIterations(EncryptorUtil.TIMES);
        setCredentialsMatcher(matcher);
    }
​
    /**
     * 获取授权信息
     *
     * @param principals
     * @return
     */
    protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
        // 获取用户名,在这里主身份(Primary Principal)就是用户名
        String userName = (String) principals.getPrimaryPrincipal();
        // 从数据库中查询用户相关的角色和权限
        User user = userService.getUserByUserName(userName);
        SimpleAuthorizationInfo sa = new SimpleAuthorizationInfo();
        // 添加角色到授权信息中
        List<String> roles = user.getRoles().stream()
                .map(Role::getName)
                .collect(Collectors.toList());
        sa.addRoles(roles);
        // 添加权限到授权信息中
        List<String> perms = user.getPerms().stream()
                .map(Permission::getName)
                .collect(Collectors.toList());
        sa.addStringPermissions(perms);
        return sa;
    }
​
​
    /**
     * 获取认证信息
     *
     * @param token
     * @return
     * @throws AuthenticationException
     */
    protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {
        // 从 token 中获取用户名
        UsernamePasswordToken upt = (UsernamePasswordToken) token;
        String userName = upt.getUsername();
        // 从数据库查询用户密码
        HashPassword correctPassword = userService.getPasswordByUserName(userName);
        if (correctPassword == null
                || correctPassword.getPassword() == null
                || correctPassword.getPassword().isEmpty()) {
            throw new UnknownAccountException(String.format("没有名称为%s的用户", userName));
        }
        // 认证通过,返回认证信息
        ByteSource salt = ByteSource.Util.bytes(correctPassword.getSalt());
        return new SimpleAuthenticationInfo(userName, correctPassword.getPassword(), salt, getName());
    }
}

大致内容与前文的 Web 应用集成没有区别,唯一的区别是:

  • UserService通过自动装配注入而非手动 new

  • 设置密码匹配器的工作通过 Spring Bean 的生命周期回调完成,而非构造方法

上面的第二步是非必要的,你同样可以用构造方法,只不过 Spring 的风格是将初始化步骤放在生命周期回调中。

2.3.配置文件

为了方便配置,将路径过滤器链的配置放在配置文件 shiro.yml 中:

my:
  shiro:
    urls:
      - /jsp/user/login.jsp=anon
      - /user/login=anon
      - /brand/list=authc, orRoles[sys_manager,dep_manager], perms["brand:view"]
      - /brand/add=authc, orRoles[sys_manager,dep_manager], perms["brand:add"]
      - /jsp/brand/add.jsp=authc, orRoles[sys_manager,dep_manager], perms["brand:add"]
      - /brand/**=authc, orRoles[sys_manager,dep_manager]
      - /jsp/**=authc
      - /user/**=authc

用一个配置类读取配置内容:

@Configuration
@PropertySource(value = "classpath:/shiro.yml", factory = YamlPropertySourceFactory.class)
@ConfigurationProperties(prefix = "my.shiro")
@Data
public class ShiroProperties {
    private List<String> urls;
}

要注意的是,Spring 的@PropertySource默认只能读取xml或properties格式的配置文件,不会读取yaml格式的配置文件。要能读取yaml格式的配置文件,需要指定一个factory属性,并实现PropertySourceFactory:

public class YamlPropertySourceFactory implements PropertySourceFactory {
​
    @Override
    public PropertySource<?> createPropertySource(String name, EncodedResource encodedResource)
            throws IOException {
        YamlPropertiesFactoryBean factory = new YamlPropertiesFactoryBean();
        factory.setResources(encodedResource.getResource());
        Properties properties = factory.getObject();
        return new PropertiesPropertySource(encodedResource.getResource().getFilename(), properties);
    }
}

2.4.配置类

之前在 Web 整合 Shiro 中,最重要的一步是在 Tomcat 启动时初始化 SecurityManger,并将 SecurityManager 添加到 SecurityUtils。

在 Spring Boot 中,SecurityManger 应该作为一个 Spring Bean 交由 Spring 容器管理。所以这里定义一个 Shiro 配置类,用于添加 Shiro 相关的 Bean 定义。

2.4.1.完整版

下面是一个完整的配置类:

@Configuration
public class ShiroConfig {
    @Autowired
    private ShiroProperties shiroProperties;
​
    /**
     * 定义 shiro 使用的 cookie
     *
     * @return
     */
    @Bean
    public SimpleCookie simpleCookie() {
        SimpleCookie simpleCookie = new SimpleCookie();
        simpleCookie.setName("ShiroSession");
        return simpleCookie;
    }
​
    /**
     * shiro 使用的 realm
     *
     * @return
     */
    @Bean
    public MyRealm myRealm() {
        return new MyRealm();
    }
​
    /**
     * 定义 session 管理器
     *
     * @return
     */
    @Bean
    public DefaultWebSessionManager webSessionManager() {
        DefaultWebSessionManager webSessionManager = new DefaultWebSessionManager();
        // 不使用 Session 的验证和更新机制
        webSessionManager.setSessionValidationSchedulerEnabled(false);
        // 设置 session 使用的 cookie
        webSessionManager.setSessionIdCookieEnabled(true);
        webSessionManager.setSessionIdCookie(simpleCookie());
        // 设置 session 的有效时长(超过后 session 失效)单位毫秒
        webSessionManager.setGlobalSessionTimeout(60 * 60 * 1000);
        return webSessionManager;
    }
​
    /**
     * 安全管理器
     *
     * @return
     */
    @Bean
    public DefaultWebSecurityManager webSecurityManager() {
        DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();
        securityManager.setRealm(myRealm());
        securityManager.setSessionManager(webSessionManager());
        return securityManager;
    }
​
    /**
     * shiro 过滤器
     *
     * @return
     */
    @Bean
    public ShiroFilterFactoryBean shiroFilterFactoryBean() {
        ShiroFilterFactoryBean shiroFilterFactoryBean = new ShiroFilterFactoryBean();
        // 设置过滤器关联的安全管理器
        shiroFilterFactoryBean.setSecurityManager(webSecurityManager());
        Map<String, Filter> filters = new HashMap<>();
        // 添加自定义过滤器
        filters.put("orRoles", new OrRolesAuthorizationFilter());
        shiroFilterFactoryBean.setFilters(filters);
        // 从配置文件读取路径对应的过滤器链
        List<String> urls = shiroProperties.getUrls();
        Map<String, String> filterChainMap = new LinkedHashMap<>();
        urls.forEach(url -> {
            String[] strings = url.split("=");
            filterChainMap.put(strings[0].trim(), strings[1].trim());
        });
        // 添加路径对应的过滤器链
        shiroFilterFactoryBean.setFilterChainDefinitionMap(filterChainMap);
        // 设置登录地址(未登录时候自动跳转)
        shiroFilterFactoryBean.setLoginUrl("/user/login");
        // 设置没有权限时跳转的地址
        shiroFilterFactoryBean.setUnauthorizedUrl("/user/login");
        return shiroFilterFactoryBean;
    }
}

配置类中包含以下几部分:

  • simpleCookie,Shiro 使用的 Session 对应的 Cookie

  • myRealm,自定义 Realm

  • webSessionManager,会话管理器,可以设置 cookie 及 Session 有效期等

  • shiroFilterFactoryBean,Shiro 过滤器工厂,可以添加自定义过滤器、路径的过滤器链等

  • webSecurityManager,安全管理器,用于添加认证用的 Realm,以及会话管理器等

2.4.2.简化版

实际上如果只是在 SpringBoot 单体应用中简单集成 Shiro,我们并不需要像上面那样定义所有的 SpringBean,可以只定义关键部分,剩下的 Shiro 会自动帮助我们定义。

@Configuration
public class SimpleShiroConfig {
    @Autowired
    private ShiroProperties shiroProperties;
​
    @Bean
    public Realm realm(){
        return new MyRealm();
    }
​
    @Bean("orRoles")
    public OrRolesAuthorizationFilter orRolesAuthorizationFilter(){
        return new OrRolesAuthorizationFilter();
    }
​
    @Bean
    public ShiroFilterChainDefinition shiroFilterChainDefinition() {
        List<String> urls = shiroProperties.getUrls();
        Map<String, String> filterChainMap = new LinkedHashMap<>();
        urls.forEach(url -> {
            String[] strings = url.split("=");
            filterChainMap.put(strings[0].trim(), strings[1].trim());
        });
        DefaultShiroFilterChainDefinition chainDefinition = new DefaultShiroFilterChainDefinition();
        // 设置路径的 shiro 过滤器链
        filterChainMap.forEach((url,filters)->{
            chainDefinition.addPathDefinition(url, filters);
        });
        return chainDefinition;
    }
}

这里只需要定义三个 Bean:

  • realm,自定义 Realm

  • orRolesAuthorizationFilter,自定义 Shiro 过滤器

  • shiroFilterChainDefinition,Shiro 过滤器链

如果使用的是简化版的配置类,就没法在过滤器工厂中设置未登录时跳转的 url,需要在配置文件 application.yml 中指定:

shiro:
  loginUrl: /jsp/user/login.jsp

3.案例

实现用户相关页面:

@Controller
@RequestMapping("/user")
public class UserController {
    @Autowired
    private UserService userService;
​
    /**
     * 加载登录页面
     *
     * @return
     */
​
    @GetMapping("/login")
    public ModelAndView loadLoginPage() {
        ModelAndView modelAndView = new ModelAndView("user/login");
        return modelAndView;
    }
​
    /**
     * 登录
     *
     * @return
     */
    @PostMapping("/login")
    public String login(@RequestParam("username") String username,
                        @RequestParam("password") String password,
                        Model model) {
        try {
            userService.login(username, password);
        } catch (Exception e) {
            // 登录失败
            model.addAttribute("errorMsg", e.getMessage());
            // 加载登录页面
            System.out.println(e.getMessage());
            return "user/login";
        }
        // 登录成功,跳转到个人主页
        return "redirect:/user/home";
    }
​
    /**
     * 个人主页
     * @return
     */
    @GetMapping("/home")
    public String home(Model model){
        String username = userService.getUsername();
        model.addAttribute("username", username);
        return "user/home";
    }
​
    @GetMapping("/logout")
    public String logout(){
        userService.logout();
        return "redirect:user/login";
    }
}

比较奇怪的是,在之前未整合 Shiro 的 SpringBoot+JSP 项目中,控制层方法可以直接使用 webapp 目录下的 JSP 文件,比如直接返回/jsp/user/login.jsp可以转发请求给对应的 JSP。但是在当前这个整合了Shiro的项目中就不能这样做,会报无法解析相关路径的错误。

3.1.视图解析器

解决的方法是添加一个视图解析器(View Resolver):

@Configuration
@EnableWebMvc
public class SpringMvcConfig implements WebMvcConfigurer {
    @Bean
    InternalResourceViewResolver viewResolver(){
        InternalResourceViewResolver viewResolver = new InternalResourceViewResolver();
        viewResolver.setViewClass(JstlView.class);
        viewResolver.setPrefix("/jsp/");
        viewResolver.setSuffix(".jsp");
        return viewResolver;
    }
    // ...  
}

我发现是@EnableWebMvc这个注解的问题,去掉后就可以正常解析 jsp 文件,不需要添加视图解析器。

因为视图解析器中定义了前缀和后缀,所以相应的,控制层方法转发和跳转的时候就不能重复添加,比如假设要跳转到/jsp/user/login.jsp这个页面,就需要改为direct:user/login,因为前缀/jsp/和后缀.jsp会由视图解析器自动添加。

4.注解

除了使用编程或者配置方式进行权限控制,Shiro 还提供通过注解方式:

注解 说明
@RequiresAuthentication 表明当前用户需是经过认证的用户
@ RequiresGuest 表明该用户需为”guest”用户
@RequiresPermissions 当前用户需拥有指定权限
@RequiresRoles 当前用户需拥有指定角色
@ RequiresUser 当前用户需为已认证用户或已记住用户

示例:

@RequestMapping("/role")
public class RoleController {
    @Autowired
    private RoleService roleService;
    /**
     * 角色管理列表页
     * @return
     */
    @GetMapping("/list")
    @RequiresRoles(value = {Role.ROLE_SYS_MANAGER, Role.ROLE_DEP_MANAGER}, logical = Logical.OR)
    @RequiresPermissions("role:view")
    public String loadRoleListPage(Model model){
        model.addAttribute("roles", roleService.getAllRoles());
        return "role/list";
    }
}

这样设置后,请求/role/list需要当前用户有管理员或部门经理角色,此外还需要有role:view权限。

这相当于在配置文件中设置了过滤器链:

/role/list=orRoles[sys_manager,dep_manager],perms["role:view"]

5.缓存

我们已经知道,Realm 中有两个方法比较重要:

  • doGetAuthenticationInfo,获取身份验证信息

  • doGetAuthorizationInfo,获取授权信息

doGetAuthenticationInfo会在用户登录时调用,用于验证用户凭证是否正确,如果正确就会进行登录。doGetAuthorizationInfo会在每次客户端请求时被调用,用于生成当前用户的角色和权限等信息,Shiro 会结合过滤器链配置、注解以及编程中的 API 检查等决定是否有访问权限。

这里边隐含的信息是后者会被大量频繁的调用,所以在doGetAuthorizationInfo方法中使用缓存会带来显而易见的性能提升。

为相关方法添加日志输出:

@Log4j2
public class MyRealm extends AuthorizingRealm {
    protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
        log.info("doGetAuthorizationInfo() is called...");
        // ...
    }
    protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {
        log.info("doGetAuthenticationInfo() is called...");
        // ...
    }
}
​
@Log4j2
@Service
public class UserServiceImpl implements UserService {
    // ...
    @Override
    public User getUserByUserName(String userName) {
        log.info("getUserByUserName() is called...");
        return userMapper.selectByUserName(userName);
    }
}

可以通过验证证实上面的说法。

Shiro 这样设计的好处是用户的权限发生改变后可以立即生效,但缺点是每次都要重新从数据库查询用户的权限信息比较影响性能。

5.1.本地缓存

如果是单体应用,最简单的缓存实现方式是:

@Configuration
public class ShiroConfig {
    // ...
    @Bean
    public DefaultWebSecurityManager webSecurityManager() {
        DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();
        // ...
        securityManager.setCacheManager(new MemoryConstrainedCacheManager());
        return securityManager;
    }
}

这里通过CachingSecurityManager.setCacheManager方法为安全管理器设置了一个缓存管理器(Cache Manager),这个缓存管理器使用了 Shiro 官方提供的一个实现MemoryConstrainedCacheManager,这个实现如同字面意思,是一个内存缓存,所以仅能用于单体应用(不能跨 JVM 分享)。

再执行测试就会发现,只有第一次请求会调用doGetAuthorizationInfo()方法,返回的结果会被缓存起来,之后每次请求都不会再执行该方法调用。

5.2.Redis

如果是集群部署的 Web 应用,就不能使用本地缓存。可以用其它支持集群的缓存中间件,这里使用 Redis。

先在 Spring Boot 项目中集成 Redis。

我们这里主要目的是缓存高频查询的UserService.getUserByUserName()方法的查询结果,为此可以定义一个对应的缓存服务类:

@Service
public class CachedUserServiceImpl implements CachedUserService {
    @Resource
    private RedisTemplate<String, User> redisTemplate;
    @Autowired
    private UserService userService;
​
    @Override
    public User getUserByUserName(String userName) {
        final String KEY = "userName:" + userName;
        ValueOperations<String, User> ops = redisTemplate.opsForValue();
        User user = ops.get(KEY);
        if (user != null) {
            return user;
        }
        // 如果缓存中没有,从数据库查询
        user = userService.getUserByUserName(userName);
        if (user!=null){
            // 保存到缓存
            ops.set(KEY, user, 30, TimeUnit.MINUTES);
        }
        return user;
    }
}

在 Realm 中使用这个缓存查询代替从数据库的查询:

public class MyRealm extends AuthorizingRealm {
    @Autowired
    private CachedUserService cachedUserService;
    // ...
    protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
        log.info("doGetAuthorizationInfo() is called...");
        // 获取用户名,在这里主身份(Primary Principal)就是用户名
        String userName = (String) principals.getPrimaryPrincipal();
        // 从数据库中查询用户相关的角色和权限
        User user = cachedUserService.getUserByUserName(userName);
        // ...
    }
}

运行程序会报错,因为实体类User有一个 Getter 方法getHashPassword没有对应的属性,所以 Jackson 无法正确反序列化:

@Data
public class User {
    // ...
    public HashPassword getHashPassword(){
        return new HashPassword(password, salt);
    }
}

可以添加一个注解@Transient解决这个问题:

@Data
public class User {
    // ...
    @Transient
    public HashPassword getHashPassword(){
        return new HashPassword(password, salt);
    }
}

被@Transient标记的 Getter 方法不参与序列化与反序列化。

像上面这样实现的缓存,只是缓存了核心部分的数据库查询,Realm 的doGetAuthorizationInfo方法依然会在每次有客户端请求时被调用。如果想缓存doGetAuthorizationInfo方法,需要像本地缓存那样,实现 Shiro 的相关缓存类型,比如 CacheManager。

实际上 Shiro 提供了两个支持集群部署的缓存实现:

  • HazelcastCacheManager

  • EhCacheManager

但是官方并没有提供基于 Redis 的实现,不过有一个开源项目 shiro-redis 提供了相关实现,并可以很容易的通过 Starter 整合到 Spring Boot 项目中。

5.3.缓存更新策略

使用缓存后虽然可以改善性能,但是随之而来的一个问题是如果当前用户的权限改变,比如本来没有权限访问相关页面,管理员修改为有权限后,缓存并不会立即生效。只有当 Redis 中的缓存超过有效期自动销毁后才会重新从数据库读取最新的权限信息。在某些系统中这是被允许的,但某些系统中是不能接受的。

缓存更新的策略要结合实际情况来处理,比如最简单的方式是在用户退出时清空缓存,或者在用户登录时重新加载缓存。但这样做的缺点是用户必须执行注销或登录操作。

如果要让用户无感,就需要在用户的权限信息发生改变时更新缓存。这里我提供一个简单的实现案例。

这里使用 Spring 事件来处理和触发缓存更新。

先定义一个用户权限发生改变的事件:

public class UserAuthChangedEvent extends ApplicationEvent {
    @Getter
    private final String username;
    public UserAuthChangedEvent(Object source, String username) {
        super(source);
        this.username = username;
    }
}

定义对应的事件监听:

@Component
public class CustomEventListener {
    @Autowired
    private CachedUserService cachedUserService;
    /**
     * 处理用户权限改变事件的监听器
     * @param event 用户权限改变事件
     */
    @EventListener
    public void handleUserAuthChanged(UserAuthChangedEvent event){
        String username = event.getUsername();
        // 删除 Shiro 使用的权限缓存
        cachedUserService.removeUserAuthCacheByUserName(username);
    }
}

在修改用户权限的 Service 方法中发布事件:

@Log4j2
@Service
public class UserServiceImpl implements UserService {
    @Autowired
    private ApplicationEventPublisher eventPublisher;
    // ...
    @Override
    public void updateUserRoles(int userId, Collection<Integer> roleIds) {
        // ...
        // 更新成功后发布用户权限修改事件
        publishUserAuthChangedEvent(userId);
    }
​
    @Override
    public void updateUserPerms(int userId, Collection<Integer> permIds) {
        // ...
        // 更新成功后发布用户权限修改事件
        publishUserAuthChangedEvent(userId);
    }
​
    /**
     * 发布用户权限修改事件
     * @param userId 用户id
     */
    private void publishUserAuthChangedEvent(int userId) {
        User user = userMapper.selectById(userId);
        eventPublisher.publishEvent(new UserAuthChangedEvent(this, user.getUsername()));
    }
}

删除 Redis 缓存使用的 API 调用了一个旧版本不支持的 Redis 命令,旧版的 Redis 可能报错,需要将版本升级。如果是 Windows 平台,可以使用 WSL 版本的 Redis,具体可以阅读这里。

可以选取一个已经登录且没有相应权限的用户,调用 Service 方法修改权限让其有权限后再尝试访问进行测试。

The End,谢谢阅读。

本文的完整示例代码可以从这里获取。

6.参考资料

  • 黑马程序员Java高级工程师技术栈-由浅入深掌握Shiro权限框架

本作品采用 知识共享署名 4.0 国际许可协议 进行许可
标签: redis shiro spring boot
最后更新:2023年9月24日

魔芋红茶

加一点PHP,加一点Go,加一点Python......

点赞
< 上一篇
下一篇 >

文章评论

取消回复

*

code

COPYRIGHT © 2021 icexmoon.cn. ALL RIGHTS RESERVED.
本网站由提供CDN加速/云存储服务

Theme Kratos Made By Seaton Jiang

宁ICP备2021001508号

宁公网安备64040202000141号