Java(二)Spring Boot + Shiro 权限控制

前后端分离、跨域、记住密码、session超时

『Shiro』可以非常快速的完成认证、授权等功能的开发,降低系统成本。实现用户身份认证,权限授权、加密、会话管理等功能。

此『Demo』为『Maven』工程,前后端分离。前端略显基础,着重后端,采用技术为:Spring Boot、MyBatis、MySQL、Shiro

依赖

『pom.xml』配置如下:

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.3.1.RELEASE</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>
    <groupId>com.xxyl</groupId>
    <artifactId>cpr_manager</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>cpr_manager</name>
    <description>Demo project for Spring Boot</description>

    <properties>
        <java.version>1.8</java.version>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
            <exclusions>
                <exclusion>
                    <groupId>org.junit.vintage</groupId>
                    <artifactId>junit-vintage-engine</artifactId>
                </exclusion>
            </exclusions>
        </dependency>

        <dependency>
            <groupId>org.apache.shiro</groupId>
            <artifactId>shiro-spring</artifactId>
            <version>1.3.2</version>
        </dependency>

        <!--实体类-->
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <version>1.16.10</version>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-configuration-processor</artifactId>
            <optional>true</optional>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-jta-atomikos</artifactId>
        </dependency>

        <!-- 分页 -->
        <dependency>
            <groupId>com.github.pagehelper</groupId>
            <artifactId>pagehelper-spring-boot-starter</artifactId>
            <version>1.2.10</version>
        </dependency>

        <!--validation-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-validation</artifactId>
        </dependency>

        <!--数据库-->
        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
            <version>8.0.15</version>
        </dependency>
        <dependency>
            <groupId>org.mybatis.spring.boot</groupId>
            <artifactId>mybatis-spring-boot-starter</artifactId>
            <version>1.3.2</version>
        </dependency>

        <!--json-->
        <dependency>
            <groupId>com.alibaba</groupId>
            <artifactId>fastjson</artifactId>
            <version>1.2.47</version>
        </dependency>

        <!-- druid连接池 -->
        <dependency>
            <groupId>com.alibaba</groupId>
            <artifactId>druid</artifactId>
            <version>1.0.9</version>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>

</project>


表设计

开发用户--角色--权限管理系统,一般会涉及五张基础表:

  • users: 用户表
  • roles: 角色表
  • permissions: 权限表
  • users_roles: 用户--角色关联表
  • roles_permissions: 角色--权限关联表

建表语句如下:

create table users (
    id int not null auto_increment,
    name varchar(100) commit '用户名',
    password varchar(100) commit '密码',
    salt varchar(100) commit '盐'
) charset=utf8 ENGINE=InnoDB;

create table roles (
    id int not null auto_increment,
    name varchar(100) commit '角色名',
    desc_ varchar(255) commit '角色描述'
) charset=utf8 ENGINE=InnoDB;

create table permission (
    id int not null auto_increment,
    name varchar(100) commit '权限名',
    desc_ varchar(255) commit '权限描述'
) charset=utf8 ENGINE=InnoDB;

create table users_roles (
    id int not null auto_increment,
    uid int commit '用户id',
    rid int commit '角色id'
) charset=utf8 ENGINE=InnoDB;

create table roles_permission (
    id int not null auto_increment,
    rid int commit '角色id',
    pid int commit '权限id'
) charset=utf8 ENGINE=InnoDB;

ShiroConfig.java

@Configuration
public class ShiroConfig {

    @Bean(name = "shiroFilter")
    public ShiroFilterFactoryBean shiroFilter(SecurityManager securityManager) {
        ShiroFilterFactoryBean shiroFilterFactoryBean = new ShiroFilterFactoryBean();
        shiroFilterFactoryBean.setSecurityManager(securityManager);
        // 没有登录或登陆失效的用户请求页面时候自动跳转到登录页
        shiroFilterFactoryBean.setLoginUrl("/login");
        // 登录的用户访问没有授权的资源自动跳转的页面
        shiroFilterFactoryBean.setUnauthorizedUrl("/notRole");

        Map<String, Filter> filterMap = new LinkedHashMap<String, Filter>();
        // 解决跨域、过滤options请求问题。
        filterMap.put("url", new SimpleCORSFilter());

        //拦截器. 
        Map<String, String> filterChainDefinitionMap = new LinkedHashMap<String, String>();

        //配置映射关系  登录页和无权限页无需认证
        filterChainDefinitionMap.put("/login", "anon");
        filterChainDefinitionMap.put("/notRole", "anon");
        // 此处可以直接配置退出,如果需要在退出时候做一些操作,则自写接口。
        //        filterChainDefinitionMap.put("/logout", "logout");

        filterChainDefinitionMap.put("/**", "url");

        shiroFilterFactoryBean.setFilters(filterMap);
        shiroFilterFactoryBean.setFilterChainDefinitionMap(filterChainDefinitionMap);
        return shiroFilterFactoryBean;
    }

    @Bean
    public SecurityManager securityManager() {
        DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();
        //设置realm.
        securityManager.setRealm(getDatabaseRealm());
        // 使用自定义的会话管理
        securityManager.setSessionManager(sessionManager());
        // 记住密码, 7天
        securityManager.setRememberMeManager(cookieRememberMeManager());
        return securityManager;
    }

    @Bean("sessionManager")
    public SessionManager sessionManager() {
        SessionManager manager = new SessionManager();
        /*使用了shiro自带缓存,
        如果设置 redis为缓存需要重写CacheManager(其中需要重写Cache)
        manager.setCacheManager(this.RedisCacheManager());*/
        manager.setSessionDAO(new EnterpriseCacheSessionDAO());
        return manager;
    }

    @Bean
    public CustomRealm getDatabaseRealm() {
        CustomRealm myShiroRealm = new CustomRealm();
        myShiroRealm.setCredentialsMatcher(hashedCredentialsMatcher());
        return myShiroRealm;
    }

    /**
     * 凭证匹配器
     * (由于我们的密码校验交给Shiro的SimpleAuthenticationInfo进行处理了
     * 所以我们需要修改下doGetAuthenticationInfo中的代码;
     * )
     *
     * @return
     */
    @Bean
    public HashedCredentialsMatcher hashedCredentialsMatcher() {
        HashedCredentialsMatcher hashedCredentialsMatcher = new HashedCredentialsMatcher();

        hashedCredentialsMatcher.setHashAlgorithmName("md5");//散列算法:这里使用MD5算法;
        hashedCredentialsMatcher.setHashIterations(2);//散列的次数,比如散列两次,相当于 md5(md5(""));

        return hashedCredentialsMatcher;
    }

    /**
     * 开启shiro aop注解支持.
     * 使用代理方式;所以需要开启代码支持;
     *
     * @param securityManager
     * @return
     */
    @Bean
    public AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor(SecurityManager securityManager) {
        AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor = new AuthorizationAttributeSourceAdvisor();
        authorizationAttributeSourceAdvisor.setSecurityManager(securityManager);
        return authorizationAttributeSourceAdvisor;
    }

    @Bean
    public LifecycleBeanPostProcessor lifecycleBeanPostProcessor() {
        return new LifecycleBeanPostProcessor();
    }

    /**
     * *
     * 开启Shiro的注解(如@RequiresRoles,@RequiresPermissions),需借助SpringAOP扫描使用Shiro注解的类,并在必要时进行安全逻辑验证
     * *
     * 配置以下两个bean(DefaultAdvisorAutoProxyCreator(可选)和AuthorizationAttributeSourceAdvisor)即可实现此功能
     * * @return
     */
    @Bean
    @DependsOn({"lifecycleBeanPostProcessor"})
    public DefaultAdvisorAutoProxyCreator advisorAutoProxyCreator() {
        DefaultAdvisorAutoProxyCreator advisorAutoProxyCreator = new DefaultAdvisorAutoProxyCreator();
        advisorAutoProxyCreator.setProxyTargetClass(true);
        return advisorAutoProxyCreator;
    }

    /**
     * 1.配置Cookie对象
     * 记住我的cookie:rememberMe
     * @return  SimpleCookie rememberMeCookie
     */
    @Bean
    public SimpleCookie rememberMeCookie() {
        SimpleCookie simpleCookie = new SimpleCookie("rememberMe");
        //simpleCookie.setHttpOnly(true);
        //单位(秒)7天
        simpleCookie.setMaxAge(60*60*24*7);
        return simpleCookie;
    }

    /**
     * 2.配置cookie管理对象
     * @return CookieRememberMeManager
     */
    @Bean
    public CookieRememberMeManager cookieRememberMeManager() {
        CookieRememberMeManager cookieRememberMeManager = new CookieRememberMeManager();
        cookieRememberMeManager.setCookie(rememberMeCookie());
        return cookieRememberMeManager;
    }
}

SimpleCORSFilter.java

@Component
@WebFilter(filterName = "SimpleCORSFilter", urlPatterns ="/*" )
public class SimpleCORSFilter implements Filter {

    private static final Logger logger = LoggerFactory.getLogger(SimpleCORSFilter.class);
    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
        throws IOException, ServletException {

        HttpServletRequest httpServletRequest = (HttpServletRequest) request;
        HttpServletResponse httpServletResponse = (HttpServletResponse) response;
        logger.info("请求拦截:"+ ((HttpServletRequest) request).getRequestURI());
        /*
         *  设置允许跨域请求
         */
        httpServletResponse.setHeader("Access-Control-Allow-Headers", "content-type, accept");
        httpServletResponse.setHeader("Access-Control-Allow-Methods", "POST");
        httpServletResponse.setStatus(200);
        httpServletResponse.setContentType("text/plain;charset=utf-8");
        httpServletResponse.setCharacterEncoding("utf-8");
        httpServletResponse.setHeader("Access-Control-Allow-Origin", "*");
        httpServletResponse.setHeader("Access-Control-Allow-Methods", "POST, GET, OPTIONS, DELETE");
        httpServletResponse.setHeader("Access-Control-Max-Age", "0");
        httpServletResponse.setHeader("Access-Control-Allow-Headers",
                                      "Origin, No-Cache, X-Requested-With, If-Modified-Since, Pragma, Last-Modified, Cache-Control, Expires, " +
                                      "Content-Type, X-E4M-With,userId,token,WG-Token, Authorization");
        httpServletResponse.setHeader("Access-Control-Allow-Credentials", "true");
        httpServletResponse.setHeader("XDomainRequestAllowed", "1");

        /*
            过虑 OPTIONS 请求
         */
        String type = httpServletRequest.getMethod();
        if (type.toUpperCase().equals("OPTIONS")) {
            return;
        }

        chain.doFilter(request, response);
    }

    @Override
    public void init(FilterConfig filterConfig) throws ServletException {
        //        String isCrossStr = filterConfig.getInitParameter("IsCross");
        //        isCross = isCrossStr.equals("true") ? true : false;
        //        System.out.println(isCrossStr);
    }
    @Override
    public void destroy() {
        //        isCross = false;
    }
}

CustomRealm.java

public class CustomRealm extends AuthorizingRealm {

    @Autowired
    private UserBiz userBiz;

    /**
     * 授权
     * @param principalCollection
     * @return
     */
    @Override
    protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principalCollection) {
        //1. 从 PrincipalCollection 中来获取登录用户的信息
        String userName = (String) principalCollection.getPrimaryPrincipal();
        User user = userBiz.findUserByUsername(userName);
        //2.添加角色和权限
        SimpleAuthorizationInfo simpleAuthorizationInfo = new SimpleAuthorizationInfo();
        Set<String> permissions = new HashSet<>();
        Set<String> roles = new HashSet<>();
        for (Role role : user.getRoles()) {
            //2.1添加角色
            roles.add(role.getName());
            for (Permission permission : role.getPermissions()) {
                //2.1.1添加权限
                permissions.add(permission.getName());
            }
        }
        simpleAuthorizationInfo.addRoles(roles);
        simpleAuthorizationInfo.addStringPermissions(permissions);
        return simpleAuthorizationInfo;
    }

    /**
     * 认证
     * @param authenticationToken
     * @return
     * @throws AuthenticationException
     */
    @Override
    protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws AuthenticationException {
        String userName = (String) authenticationToken.getPrincipal();

        //同一账号同一时间只能在一个地方登陆
        DefaultWebSecurityManager securityManager = (DefaultWebSecurityManager) SecurityUtils.getSecurityManager();
        DefaultWebSessionManager sessionManager = (DefaultWebSessionManager)securityManager.getSessionManager();
        Collection<Session> sessions = sessionManager.getSessionDAO().getActiveSessions();//获取当前已登录的用户session列表
        for(Session session:sessions){
            //清除该用户以前登录时保存的session
            if(userName.equals(String.valueOf(session.getAttribute(DefaultSubjectContext.PRINCIPALS_SESSION_KEY)))) {
                sessionManager.getSessionDAO().delete(session);
            }
        }

        //根据用户名从数据库获取密码
        User user = userBiz.findUserByUsername(userName);
        String passwordInDB = "", salt = "";
        if (user != null) {
            passwordInDB = user.getPassword();
            salt = user.getSalt();
        }
        return new SimpleAuthenticationInfo(userName, passwordInDB, ByteSource.Util.bytes(salt), getName());
    }
}

SessionManager.java

public class SessionManager extends DefaultWebSessionManager {
    private static final Logger log = LoggerFactory.getLogger(DefaultWebSessionManager.class);
    private String authorization = "Authorization";

    /**
     * 重写获取sessionId的方法调用当前Manager的获取方法
     *
     * @param request
     * @param response
     * @return
     */
    @Override
    protected Serializable getSessionId(ServletRequest request, ServletResponse response) {
        return this.getReferencedSessionId(request, response);
    }

    /**
     * 获取sessionId从请求中
     *
     * @param request
     * @param response
     * @return
     */
    private Serializable getReferencedSessionId(ServletRequest request, ServletResponse response) {
        String id = this.getSessionIdCookieValue(request, response);
        if (id != null) {
            request.setAttribute(ShiroHttpServletRequest.REFERENCED_SESSION_ID_SOURCE, "cookie");
        } else {
            id = this.getUriPathSegmentParamValue(request, "JSESSIONID");
            if (id == null) {
                // 获取请求头中的session
                id = WebUtils.toHttp(request).getHeader(this.authorization);
                if (id == null) {
                    String name = this.getSessionIdName();
                    id = request.getParameter(name);
                    if (id == null) {
                        id = request.getParameter(name.toLowerCase());
                    }
                }
            }
            if (id != null) {
                request.setAttribute(ShiroHttpServletRequest.REFERENCED_SESSION_ID_SOURCE, "url");
            }
        }

        if (id != null) {
            request.setAttribute(ShiroHttpServletRequest.REFERENCED_SESSION_ID, id);
            request.setAttribute(ShiroHttpServletRequest.REFERENCED_SESSION_ID_IS_VALID, Boolean.TRUE);
        }
        return id;
    }

    private String getSessionIdCookieValue(ServletRequest request, ServletResponse response) {
        if (!this.isSessionIdCookieEnabled()) {
            log.debug("Session ID cookie is disabled - session id will not be acquired from a request cookie.");
            return null;
        } else if (!(request instanceof HttpServletRequest)) {
            log.debug("Current request is not an HttpServletRequest - cannot get session ID cookie.  Returning null.");
            return null;
        } else {
            HttpServletRequest httpRequest = (HttpServletRequest) request;
            return this.getSessionIdCookie().readValue(httpRequest, WebUtils.toHttp(response));
        }
    }

    private String getUriPathSegmentParamValue(ServletRequest servletRequest, String paramName) {
        if (!(servletRequest instanceof HttpServletRequest)) {
            return null;
        } else {
            HttpServletRequest request = (HttpServletRequest) servletRequest;
            String uri = request.getRequestURI();
            if (uri == null) {
                return null;
            } else {
                int queryStartIndex = uri.indexOf(63);
                if (queryStartIndex >= 0) {
                    uri = uri.substring(0, queryStartIndex);
                }

                int index = uri.indexOf(59);
                if (index < 0) {
                    return null;
                } else {
                    String TOKEN = paramName + "=";
                    uri = uri.substring(index + 1);
                    index = uri.lastIndexOf(TOKEN);
                    if (index < 0) {
                        return null;
                    } else {
                        uri = uri.substring(index + TOKEN.length());
                        index = uri.indexOf(59);
                        if (index >= 0) {
                            uri = uri.substring(0, index);
                        }

                        return uri;
                    }
                }
            }
        }
    }

    private String getSessionIdName() {
        String name = this.getSessionIdCookie() != null ? this.getSessionIdCookie().getName() : null;
        if (name == null) {
            name = "JSESSIONID";
        }
        return name;
    }
}

登录/退出

@Controller
public class LoginController {

    @Autowired
    public UserBiz userBiz;

    @RequestMapping(value = "/login", method = RequestMethod.POST)
    @ResponseBody
    public JSONObject login(@Validated LoginDTO loginDTO) {
        // 从SecurityUtils里边创建一个 subject
        Subject subject = SecurityUtils.getSubject();
        // 在认证提交前准备 token(令牌)
        UsernamePasswordToken token = new UsernamePasswordToken(loginDTO.getUsername(), loginDTO.getPassword());
        JSONObject jsonObject = new JSONObject();
        // 执行认证登陆
        try {
            // 无操作30分钟后登录失效。
            subject.getSession().setTimeout(1800000);
            subject.login(token);
            
            jsonObject.put("username", user.getNickname());
        } catch (AuthenticationException ae) {
            jsonObject.put("msg", "用户名或密码不正确");
            return jsonObject;
        }
        if (subject.isAuthenticated()) {
            jsonObject.put("sessionId",subject.getSession().getId());

            Session session = subject.getSession();
            session.setAttribute("subject", subject);

            return jsonObject;
        } else {
            token.clear();
            jsonObject.put("msg", "登录失败");
            return jsonObject;
        }
    }

    @ResponseBody
    @RequestMapping(value = "/logout")
    public JSONObject logout() {
        try {
            Subject lvSubject=SecurityUtils.getSubject();
            lvSubject.logout();
            // 如果退出时候有其他操作,可以写在此处
            JSONObject jsonObject = new JSONObject();
            jsonObject.put("msg", "退出成功");
            return jsonObject;
        } catch (Exception e){
            jsonObject.put("msg", "退出失败");
            return jsonObject;
        }
    }
}

注册

@RequiresPermissions("addUser")   // 使用注解方式来验证权限
@ResponseBody
@RequestMapping(value = "/addUser")
public JSONObject addUser(User user, @RequestParam String role){
    try {
        JSONObject jsonObject = new JSONObject();
        // 验证输入
        ... ...
        // 生成密码以及盐
        PasswordAndSalt passwordAndSalt= new PasswordAndSalt();
        String[] strings= passwordAndSalt.getPasswordAndSalt("", 2);
        user.setPassword(strings[1]);
        user.setSalt(strings[0]);
        Date d = new Date();
        SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
        user.setCreatetime(sdf.format(d));
        userMapper.addUser(user);     
        int id= user.getId();  // 插入后的id
        String[] strings1= role.split(";");
        List<UserRole> list= new ArrayList<>();
        for (String str : strings1) {
            UserRole userRole= new UserRole();
            userRole.setUid(id);
            userRole.setRid(Integer.parseInt(str));
            list.add(userRole);
        }
        courseMapper.addUserRole(list);       // 插入用户-角色关系    
    }catch (Exception e){
        e.printStackTrace();
        jsonObject.put("msg", "账号添加失败");
        return jsonObject;
    }
    jsonObject.put("msg", "账号添加成功");
    return jsonObject;
}

PasswordAndSalt.java

public class PasswordAndSalt {

    public static String password = "123456";
    public static int number= 2;            // 加密次数

    public String[] getPasswordAndSalt(String pwd, int num){
        if(pwd.equals("")){
            pwd= password;
        }
        if(num<= 0){
            num= number;
        }
        String newSalt = new SecureRandomNumberGenerator().nextBytes().toString(); //盐量随机
        String newPwd= new Md5Hash(pwd, newSalt, num).toString();  // 新密码
        String[] strings= new String[2];
        strings[0]= newSalt;
        strings[1]= newPwd;
        return strings;
    }
}

修改密码

@RequiresPermissions("changePassword")
@ResponseBody
@RequestMapping("/changePassword")
public JSONObject showUser(String old, String pwd1, String pwd2) {
    try {
        JSONObject jsonObject = new JSONObject();
        String loginAccount = SecurityUtils.getSubject().getPrincipal().toString();
        if(!pwd1.equals(pwd2)){
            jsonObject.put("msg", "密码不一致,请重新输入");
            return jsonObject;
        }
        //根据用户名从数据库获取密码
        User user = userBiz.findUserByUsername(loginAccount);
        String passwordInDB = "", salt = "";
        if (user != null) {
            passwordInDB = user.getPassword();
            salt = user.getSalt();
        }else {
            jsonObject.put("msg", "用户不存在");
            return jsonObject;
        }

        String str= new Md5Hash(old, salt, 2).toString();

        if(!str.equals(passwordInDB)){
            jsonObject.put("msg", "请输入正确的原始密码");
            return jsonObject;
        }
        String[] strings= passwordAndSalt.getPasswordAndSalt(pwd1, 2);
        String newSalt = strings[0]; //盐量随机
        String newPwd= strings[1];  // 新密码
        Boolean flag= userBiz.changePassword(newPwd, newSalt, user.getId());
        jsonObject.put("msg", "密码修改成功");
        return jsonObject;
    }catch (Exception e){
        e.printStackTrace();
        jsonObject.put("msg", "密码修改失败");
        return jsonObject;
    }
}

全局异常处理器

注解验证角色和权限的话无法捕捉异常,从而无法正确的返回给前端错误信息,所以加了一个类用于拦截异常,具体代码如下:

/**
 * 全局异常处理器
 */
@RestControllerAdvice
public class MyExceptionHandler {

    @ExceptionHandler(value = AuthorizationException.class)
    public Map<String, String> handleException(AuthorizationException e) {
        Map<String, String> result = new HashMap<String, String>();
        result.put("status", "400");
        //获取错误中中括号的内容
        String message = e.getMessage();
        String msg= null;
        try {
            msg = message.substring(message.indexOf("[")+1,message.indexOf("]"));
        }catch (StringIndexOutOfBoundsException e1){
            result.put("msg", "对不起,您没有权限,请先登录");
            return result;
        }
        //判断是角色错误还是权限错误
        if (message.contains("role")) {
            result.put("msg", "对不起,您没有" + msg + "角色");
        } else if (message.contains("permission")) {
            result.put("msg", "对不起,您没有" + msg + "权限");
        } else {
            result.put("msg", "对不起,您的权限有误");
        }
        return result;
    }
}

更新时间:2021-04-29 14:47:03

本文由 caroly 创作,如果您觉得本文不错,请随意赞赏
采用 知识共享署名4.0 国际许可协议进行许可
本站文章除注明转载 / 出处外,均为本站原创或翻译,转载前请务必署名
原文链接:https://caroly.fun/archives/springbootshiro权限控制
最后更新:2021-04-29 14:47:03

评论

Your browser is out of date!

Update your browser to view this website correctly. Update my browser now

×