一直想好好的学习一下安全方面的框架,自己对于这个方面的知识很欠缺,借助当前公司项目的机会,认真的研究了两天Shiro这个高度可定制的框架(虽然在Spring Boot中集成Spring Security更为方便,但是看了一天的相关资料,感觉还是Shiro更简单,以后再认真学习Spring Security)。

项目数据库使用的是MongoDB,稍后使用MySQL做一个集成,毕竟用到MongoDB的不多,其实都是大同小异,不过在SpringBoot中使用MongoDB特别方便。

1 准备

  • Spring Boot 2.x
  • Shiro
  • Redis
  • Maven 3.6
  • JDK 1.8
  • IDEA

2 Shiro介绍

直达官网:http://shiro.apache.org

image.png

  • Shiro中四大模块如图:
    • Authentication,身份证认证,一般就是登录
    • Authorization,授权,给用户分配角色或者访问某些资源的权限
    • Session Management, 用户的会话管理员,多数情况下是web session
    • Cryptography, 数据加解密,比如密码加解密等

Shiro权限控制流程及相关概念(http://shiro.apache.org/architecture.html),其中:

  • Subject:主体,如用户或程序,主体去访问系统或者资源
  • SecurityManager:安全管理器,Subject的认证和授权都需在安全管理器下进行
  • Authenticator:认证器,主要负责Subject的认证
  • Realm:数据域,Shiro和安全数据的连接器,类似于jdbc连接数据库; 通过realm获取认证授权相关信息,在集成时这个部分需要重写Shiro指定的方法
  • Authorizer:授权器,主要负责Subject的授权, 控制subject拥有的角色或者权限
  • Cryptography:加解密,Shiro中包含易于使用和理解的数据加解密方法,简化了很多复杂的api
  • Cache Manager:缓存管理器,比如认证或授权信息,通过缓存进行管理,提高性能

3 集成Shiro

3.1 数据库设计

image.png

3.2 创建项目

使用IDEA创建一个空的Spring Boot项目,勾选相关web依赖和数据库依赖,这里把相关依赖全部罗列出来

    <properties>
        <java.version>1.8</java.version>
        <shiro-spring.version>1.5.1</shiro-spring.version>
        <shiro-redis.version>3.2.3</shiro-redis.version>
        <druid.version>1.1.20</druid.version>
        <fastjson.version>1.2.67</fastjson.version>
        <mybatis-plus.version>3.3.0</mybatis-plus.version>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
            <exclusions>
                <exclusion>
                    <groupId>org.springframework.boot</groupId>
                    <artifactId>spring-boot-starter-json</artifactId>
                </exclusion>
            </exclusions>
        </dependency>
        <!-- FastJson -->
        <dependency>
            <groupId>com.alibaba</groupId>
            <artifactId>fastjson</artifactId>
            <version>${fastjson.version}</version>
        </dependency>
        <!--mysql-->
        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
        </dependency>
        <!-- druid数据库连接池 -->
        <dependency>
            <groupId>com.alibaba</groupId>
            <artifactId>druid</artifactId>
            <version>${druid.version}</version>
        </dependency>
        <!--mybatis-plus-->
        <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus-boot-starter</artifactId>
            <version>${mybatis-plus.version}</version>
        </dependency>
        <!--整合shiro-->
        <dependency>
            <groupId>org.apache.shiro</groupId>
            <artifactId>shiro-spring</artifactId>
            <version>${shiro-spring.version}</version>
        </dependency>
        <!--整合shiro-redis缓存插件-->
        <dependency>
            <groupId>org.crazycake</groupId>
            <artifactId>shiro-redis</artifactId>
            <version>${shiro-redis.version}</version>
            <exclusions>
                <exclusion>
                    <groupId>org.apache.shiro</groupId>
                    <artifactId>shiro-core</artifactId>
                </exclusion>
            </exclusions>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>
    </dependencies>

3.3 配置application.yml文件

配置数据库连接相关信息

spring:
  datasource:
    type: com.alibaba.druid.pool.DruidDataSource
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://localhost:3306/db_role?useUnicode=true&characterEncoding=utf-8&useSSL=false&serverTimezone=UTC
    username: db_role
    password: db_role

  redis:
    database: 1                     # Redis数据库索引(默认为0)
    host: 192.168.213.146     # Redis服务器地址
    port: 6379                      # Redis服务器连接端口
    password: coctrl              # Redis服务器连接密码(默认为空)
    timeout: 0                        # 连接超时时间(毫秒)
    jedis:
      pool:
        max-active: 8
        max-idle: 8
        max-wait: -1ms
        min-idle: 0

# SQL打印
mybatis-plus:
  configuration:
    log-impl: org.apache.ibatis.logging.stdout.StdOutImpl

3.4 集成mybatis-plus

3.4.1 entity

实体类与数据库一一对应就行,可以配合mybatis-plus直接自动生成

3.4.2 mapper

  • UserMapper.java

只写了一个用于登录是通过用户名查对应信息的方法

@Component
public interface UserMapper extends BaseMapper<User> {
     /**
     * 根据用户名查询用户信息,登录
     * @param username
     * @return
     */
    UserVO findUserInfoByUsername(String username);
}

UserVO是一个自定义的实体类,用于封装用户、角色、权限信息,具体可以看项目源码

  • UserMapper.xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.kangaroohy.shiroredis.mapper.UserMapper">

    <resultMap id="userInfoMap" type="com.kangaroohy.shiroredis.domain.entity.vo.UserVO" >
        <id property="id" column="u_id" />
        <result property="username" column="u_username"/>
        <result property="password" column="u_password"/>
        <result property="gender" column="u_gender"/>
        <result property="updateTime" column="u_updateTime"/>
        <collection property="roleList" ofType="com.kangaroohy.shiroredis.domain.entity.vo.RoleVO" >
            <id property="role.id" column="r_id" />
            <result property="role.name" column="r_name"/>
            <result property="role.description" column="r_description"/>
            <collection property="permissionList" ofType="com.kangaroohy.shiroredis.domain.entity.po.Permission" >
                <id property="id" column="p_id" />
                <result property="name" column="p_name"/>
                <result property="url" column="p_url"/>
            </collection>
        </collection>
    </resultMap>

    <select id="findUserInfoByUsername" resultMap="userInfoMap">
        SELECT
            u.id u_id,
            u.username u_username,
            u.password u_password,
            u.gender u_gender,
            u.update_time u_updateTime,
            r.id r_id,
            r.name r_name,
            r.description r_description,
            p.id p_id,
            p.name p_name,
            p.url p_url
        FROM
            t_user u
        LEFT JOIN t_user_role ur ON ur.uid = u.id
        LEFT JOIN t_role r ON r.id = ur.rid
        LEFT JOIN t_role_permission rp ON rp.rid = r.id
        LEFT JOIN t_permission p ON p.id = rp.pid
        WHERE
            u.username = #{username}
    </select>
</mapper>

3.4.3 service

这个地方就是一个简单的调用mapper中的方法,具体可以看源码

不要忘记启动器类配置mybatis的mapper扫描路径,这个集成mybatis-plus的时候应该有配置

@MapperScan("com.kangaroohy.shiroredis.mapper")

3.5 配置Shiro

3.5.1 自定义Realm:CustomRealm.java

@Slf4j
public class CustomRealm extends AuthorizingRealm {
    @Autowired
    private UserService userService;

    /**
     * 授权时调用(权限校验)
     *
     * @param principalCollection
     * @return
     */
    @Override
    protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principalCollection) {
        //获得当前对象
        UserVO user = (UserVO) principalCollection.getPrimaryPrincipal();

        log.info("用户 {} 调用了 doGetAuthorizationInfo 进行授权", user.getUsername());

        UserVO info = userService.findUserInfoByUsername(user.getUsername());

        Set<String> stringRolesList = new HashSet<>();
        Set<String> stringPermissionsList = new HashSet<>();

        List<RoleVO> roleList = info.getRoleList();
        for (RoleVO role : roleList) {
            stringRolesList.add(role.getRole().getName());
            List<Permission> permissionList = role.getPermissionList();
            for (Permission permission : permissionList){
                if (permission != null){
                    stringPermissionsList.add(permission.getName());
                }
            }
        }

        SimpleAuthorizationInfo simpleAuthorizationInfo = new SimpleAuthorizationInfo();
        simpleAuthorizationInfo.addRoles(stringRolesList);
        simpleAuthorizationInfo.addStringPermissions(stringPermissionsList);
        return simpleAuthorizationInfo;
    }

    /**
     * 登录时调用
     *
     * @param authenticationToken
     * @return
     * @throws AuthenticationException
     */
    @Override
    protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws AuthenticationException {
        //从token中获得用户信息,这个token是用户的输入信息
        String username = (String) authenticationToken.getPrincipal();
        UserVO info = userService.findUserInfoByUsername(username);

        //取密码
        String password = info.getPassword();
        if (password == null || "".equals(password)) {
            return null;
        }
        //设置盐值,使用账号作为盐值
        ByteSource salt = ByteSource.Util.bytes(username);
        //参数一传入对象,便于全局获取当前对象信息
        return new SimpleAuthenticationInfo(info, password, salt, getName());
    }
}

Realm能做的工作主要有以下几个方面:

  • 验证是否能登录,并返回验证信息(getAuthenticationInfo方法)
  • 验证是否有访问指定资源的权限,并返回所拥有的所有权限(getAuthorizationInfo方法)
  • 判断是否支持token(例如:HostAuthenticationToken,UsernamePasswordToken等)(supports方法)

自定义Realm中实现的是前两个

3.5.2 自定义SessionManager:CustomSessionManager.java

将生成的sessionId作为认证时的token,登录之后的每次请求,header中必须携带token参数

public class CustomSessionManager extends DefaultWebSessionManager {

    private static final String AUTHORIZATION = "token";

    public CustomSessionManager() {
        super();
    }

    @Override
    protected Serializable getSessionId(ServletRequest request, ServletResponse response) {
        String sessionId = WebUtils.toHttp(request).getHeader(AUTHORIZATION);
        if (sessionId != null) {
            request.setAttribute(ShiroHttpServletRequest.REFERENCED_SESSION_ID_SOURCE,
                    ShiroHttpServletRequest.COOKIE_SESSION_ID_SOURCE);
            request.setAttribute(ShiroHttpServletRequest.REFERENCED_SESSION_ID, sessionId);
            request.setAttribute(ShiroHttpServletRequest.REFERENCED_SESSION_ID_IS_VALID, Boolean.TRUE);
            return sessionId;
        } else {
            return super.getSessionId(request, response);
        }
    }
}

3.5.3 自定义sessionId生成器:CustomSessionId.java

public class CustomSessionId implements SessionIdGenerator {
    @Override
    public Serializable generateId(Session session) {
        return "kangaroohy" + UUID.randomUUID().toString().replace("-", "");
    }
}

3.5.4 shiro配置文件:ShiroConfig.java

@Configuration
public class ShiroConfig {

    @Value("${spring.redis.database}")
    private Integer database;

    @Value("${spring.redis.host}")
    private String host;

    @Value("${spring.redis.port}")
    private Integer port;

    @Value("${spring.redis.password}")
    private String password;

    @Bean(name = "shiroFilter")
    public ShiroFilterFactoryBean shiroFilter(SecurityManager securityManager) {
        ShiroFilterFactoryBean shiroFilterFactoryBean = new ShiroFilterFactoryBean();

        //设置securityManager
        shiroFilterFactoryBean.setSecurityManager(securityManager);
        //需要登陆的接口,没有登陆就访问需要登陆的接口,则调用这个接口(非前后端分离,则调用登录界面)
        shiroFilterFactoryBean.setLoginUrl("/pub/need_login");
        //登录成功,跳转url,前后端分离的情况下,则没有这个接口的调用
        shiroFilterFactoryBean.setSuccessUrl("/");
        //已经登陆,但是访问的接口没有权限,类似403界面
        shiroFilterFactoryBean.setUnauthorizedUrl("/pub/unauthorized");

        //自定义退出后重定向的地址,前后端分离,用于返回退出成功信息
        LogoutFilter logout = new LogoutFilter();
        logout.setRedirectUrl("/pub/logout");

        //设置自定义filter
        Map<String, Filter> filterMap = new LinkedHashMap<>();
        //自定义退出filter
        filterMap.put("logout",logout);
        shiroFilterFactoryBean.setFilters(filterMap);

        //拦截器路径,务必设置为LinkedHashMap,否则部分路径拦截时有时无(不生效),因为如果使用HashMap,无序,而LinkedHashMap,有序
        Map<String, String> filterChainDefinitionMap = new LinkedHashMap<>();
        //退出过滤器,退出成功后,默认返回LoginUrl接口,即登录页,通过自定义,改到/pub/logout接口
        filterChainDefinitionMap.put("/logout", "logout");
        //匿名可访问
        filterChainDefinitionMap.put("/pub/**", "anon");
        //登录后可以访问
        filterChainDefinitionMap.put("/user/**", "authc");
        //通过认证才能访问
        filterChainDefinitionMap.put("/**", "authc");

        shiroFilterFactoryBean.setFilterChainDefinitionMap(filterChainDefinitionMap);
        return shiroFilterFactoryBean;
    }

    @Bean(name = "securityManager")
    public SecurityManager securityManager() {
        DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();
        //如果不是前后端分离,不用设置这个
        securityManager.setSessionManager(sessionManager());
        //设置自定义的cacheManager
        securityManager.setCacheManager(cacheManager());
        //设置realm(推荐放到最后)
        securityManager.setRealm(customRealm());
        return securityManager;
    }

    /**
     * 配置具体的cache实现类
     */
    public org.crazycake.shiro.RedisCacheManager cacheManager(){
        org.crazycake.shiro.RedisCacheManager redisCacheManager = new RedisCacheManager();
        redisCacheManager.setRedisManager(redisManager());
        //过期时间,单位 s
        redisCacheManager.setExpire(Constant.CACHE_EXPIRE_TIME);
        redisCacheManager.setPrincipalIdFieldName(Constant.ACCOUNT);
        redisCacheManager.setKeyPrefix(Constant.ACTIVE_SHIRO_CACHE);
        return redisCacheManager;
    }

    /**
     * 自定义realm
     * @return
     */
    @Bean(name = "customRealm")
    public CustomRealm customRealm() {
        CustomRealm realm = new CustomRealm();
        realm.setCredentialsMatcher(credentialsMatcher());
        return realm;
    }

    /**
     * 密码加解密规则设置
     * @return
     */
    @Bean(name = "credentialsMatcher")
    public HashedCredentialsMatcher credentialsMatcher() {
        HashedCredentialsMatcher credentialsMatcher = new HashedCredentialsMatcher();
        //设置散列算法,这里使用MD5
        credentialsMatcher.setHashAlgorithmName(Constant.HASH_NAME);
        //散列次数
        credentialsMatcher.setHashIterations(Constant.HASH_TIME);
        return credentialsMatcher;
    }

    @Bean(name = "sessionManager")
    public SessionManager sessionManager(){
        CustomSessionManager sessionManager = new CustomSessionManager();
        //过期时间,默认30分钟超时,方法中单位是毫秒,此处15分钟过期:15 * 60 * 1000
        sessionManager.setGlobalSessionTimeout(Constant.SESSION_EXPIRE_TIME);
        //配置session持久化
        sessionManager.setSessionDAO(redisSessionDAO());
        sessionManager.setSessionIdCookieEnabled(false);
        sessionManager.setSessionIdUrlRewritingEnabled(false);
        sessionManager.setDeleteInvalidSessions(true);
        return sessionManager;
    }

    /**
     *  自定义Session持久化
     * @return
     */
    public RedisSessionDAO redisSessionDAO(){
        RedisSessionDAO redisSessionDAO = new RedisSessionDAO();
        redisSessionDAO.setRedisManager(redisManager());
        redisSessionDAO.setSessionIdGenerator(new CustomSessionId());
        redisSessionDAO.setKeyPrefix(Constant.ACTIVE_SHIRO_SESSION);
        return redisSessionDAO;
    }

    /**
     *  配置redisManager
     */
    public RedisManager redisManager(){
        RedisManager redisManager = new RedisManager();
        redisManager.setDatabase(database);
        //在shiro-redis 3.2.3版本中,host合并了port
        redisManager.setHost(host + ":" + port);
        //redisManager.setPort(port);
        redisManager.setPassword(password);
        return redisManager;
    }

    /**
     * 管理shiro一些Bean的生命周期,初始化和销毁
     * @return
     */
    @Bean(name = "lifecycleBeanPostProcessor")
    public static LifecycleBeanPostProcessor lifecycleBeanPostProcessor(){
        return new LifecycleBeanPostProcessor();
    }

    /**
     * 使注解生效,不加这个,shrio的AOP注解不生效
     * 如 Controller中 shiro的 @RequiresGust(游客可以访问) 注解
     * @return
     */
    @Bean(name = "authorizationAttributeSourceAdvisor")
    public AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor(){
        AuthorizationAttributeSourceAdvisor sourceAdvisor = new AuthorizationAttributeSourceAdvisor();
        sourceAdvisor.setSecurityManager(securityManager());
        return sourceAdvisor;
    }

    /**
     * 用来扫描上下文,寻找所有的Advisor(通知器),将符合条件的Advisor应用到切入点的Bean中
     * 需要在LifecycleBeanPostProcessor创建之后才可以创建
     * @return
     */
    @Bean
    @DependsOn("lifecycleBeanPostProcessor")
    public static DefaultAdvisorAutoProxyCreator getDefaultAdvisorAutoProxyCreator(){
        DefaultAdvisorAutoProxyCreator defaultAdvisorAutoProxyCreator = new DefaultAdvisorAutoProxyCreator();
        // 强制使用cglib,防止重复代理和可能引起代理出错的问题
        // https://zhuanlan.zhihu.com/p/29161098
        defaultAdvisorAutoProxyCreator.setProxyTargetClass(true);
        return defaultAdvisorAutoProxyCreator;
    }
}

3.5.5 登录用到的controller:PublicController.java

@RestController
@RequestMapping("/pub")
@Slf4j
public class PublicController {

    UserService userService;

    public PublicController(UserService userService) {
        this.userService = userService;
    }

    @GetMapping("/need_login")
    public RestResult<String> needLogin() {
        return RestResult.error(RestCode.NEED_LOGIN_ERROR);
    }

    @GetMapping("/unauthorized")
    public RestResult<String> unauthorized() {
        return RestResult.error(RestCode.AUTHORIZATION_ERROR);
    }

    @PostMapping("/login")
    public RestResult<UserVO> login(@RequestParam String username, @RequestParam String password, HttpServletResponse response) {
        Subject subject = SecurityUtils.getSubject();
        //踢出之前登录状态,同一账号 同一时间 只能在同一个地方登录
        kickOutBefore(username.trim());

        UserVO userInfo = userService.findUserInfoByUsername(username.trim());
        //用户名查询用户
        if (userInfo == null) {
            return RestResult.error("账号不存在");
        }
        try {
            UsernamePasswordToken userPwdToken = new UsernamePasswordToken(username.trim(), password.trim());
            subject.login(userPwdToken);
            String token = subject.getSession().getId().toString();
            UserVO info = userService.findUserInfoByUsername(username.trim());
            info.setPassword(null);
            JedisUtil.setJson(Constant.ACTIVE_SHIRO_TOKEN + username.trim(), token, Constant.ONLINE_TOKEN_EXPIRE_TIME);
            response.setHeader(Constant.AUTHORIZATION, token);
            response.setHeader("Access-Control-Expose-Headers", Constant.AUTHORIZATION);
            return RestResult.ok(info);
        } catch (Exception e) {
            e.printStackTrace();
            return RestResult.error("用户名或密码错误");
        }
    }

    /**
     * 根据userId踢出 同一用户 不同浏览器登录session
     * @param username 账号
     */
    private void kickOutBefore(String username) {
        String key = Constant.ACTIVE_SHIRO_TOKEN + username.trim();
        if (JedisUtil.exists(key)) {
            String value = JedisUtil.getJson(key);
            if (JedisUtil.exists(Constant.ACTIVE_SHIRO_SESSION + value)) {
                JedisUtil.delete(key, Constant.ACTIVE_SHIRO_SESSION + value);
            } else {
                JedisUtil.delete(key);
            }
        }
    }
}

3.6 返回JSON数据的自定义类

  • RestCode.java
public enum RestCode {
    //运行时错误
    UNKNOWN_ERROR(-1, "Unknown Error! Contact the administrator if this problem continues."),
    //操作成功
    SUCCESS(200, "SUCCESS"),
    //请求参数错误、不合法
    PARAM_ERROR(400, "Illegal Parameter!"),
    //需要登录才能访问
    NEED_LOGIN_ERROR(401, "Please login and visit again!"),
    //没有访问权限,禁止访问
    AUTHORIZATION_ERROR(403, "You are forbidden to view it!"),
    //资源不存在
    NOTFOUND_ERROR(404, "Not Found"),
    //方法不支持
    NOT_SUPPORT_ERROR(500, "Not support this method");

    private Integer code;
    private String message;

    RestCode(){
    }

    RestCode(Integer code, String message) {
        this.code = code;
        this.message = message;
    }

    public Integer getCode() {
        return code;
    }

    public void setCode(Integer code) {
        this.code = code;
    }

    public String getMessage() {
        return message;
    }

    public void setMessage(String message) {
        this.message = message;
    }
}
  • RestResult.java
@Data
public class RestResult<T> implements Serializable {

    private static final long serialVersionUID = 7711799662216684129L;

    @JSONField(ordinal = 1)
    public int code;

    @JSONField(ordinal = 2)
    private String msg;

    @JSONField(ordinal = 3)
    private T data;

    public RestResult() {
    }

    public RestResult(int code, String msg) {
        this.code = code;
        this.msg = msg;
    }

    public RestResult(int code, String msg, T data) {
        this.code = code;
        this.msg = msg;
        this.data = data;
    }

    public static <T> RestResult<T> ok() {
        return new RestResult<>(RestCode.SUCCESS.getCode(), RestCode.SUCCESS.getMessage());
    }

    public static <T> RestResult<T> ok(String msg) {
        return new RestResult<>(RestCode.SUCCESS.getCode(), msg);
    }

    public static <T> RestResult<T> ok(T data) {
        return new RestResult<>(RestCode.SUCCESS.getCode(), RestCode.SUCCESS.getMessage(), data);
    }

    public static <T> RestResult<T> error(String msg) {
        return new RestResult<>(RestCode.UNKNOWN_ERROR.getCode(), msg);
    }

    public static <T> RestResult<T> error(RestCode restCode) {
        return new RestResult<>(restCode.getCode(), restCode.getMessage());
    }

    public static <T> RestResult<T> error(RestCode restCode, String msg) {
        return new RestResult<>(restCode.getCode(), msg);
    }

    public static <T> RestResult<T> error(RestCode restCode, String msg, T data) {
        return new RestResult<>(restCode.getCode(), msg, data);
    }
}

4 Demo下载

无Redis缓存版本:https://github.com/kangaroo1122/ssm_shiro

有Redis缓存版本:https://github.com/kangaroo1122/shiro-redis

两个版本都可以直接下载,按照给的数据库设计,创建数据库,修改数据库地址即可运行。

用户密码加密方式:可以查看自定义Realm,这样就能自己修改数据库用户数据了

其他

关于redis的配置文件和操作redis数据库的util,可以直接查看demo源码,Redis版本还集成了mybatis-plus枚举类型转换器,具体可以看mybatis-plus的官方介绍.

上一篇 下一篇