参考文献:
Spring Security 进阶篇
Spring Security在Spring Boot环境下的学习

示例代码:
链接: https://pan.baidu.com/s/1HYAuU9IwuGbTe9YzjFdv7A 密码: s41m

一、Spring Security 概述

1.1 框架概述

Spring Security 是 Spring 家族中的一个安全管理框架,Spring Security 的两大核心功能就是认证(authentication)和授权(authorization)。

1.2 常用术语

  • 认证 :你是什么人。
  • 授权 :你能做什么。
  • 用户 :主要包括用户名称、用户密码和当前用户所拥有的角色信息,可用于实现认证操作。
  • 角色 :主要包括角色名称、角色描述和当前角色所拥有的权限信息,可用于实现授权操作。

1.3 常用单词

  • 认证 :authentication
  • 授权 :authorization
  • 用户 :user
  • 角色 :role
  • 登录 :login
  • 注销 :logout

1.4 环境准备

打开基础代码:

请在配套资料中,找到 spring boot 专用基础代码,使用 Idea 打开 spring-boot-security,这只是一个很普通的 spring boot + mybatis 项目,如果你有 spring boot + mybatis 项目的基础,相信你一定能看得懂,我们从左侧的菜单栏可以看到有四个部分,其中“产品管理”、“订单管理”虽然可以进行添加和查询所有,但是,这两个功能并没有和数据库交互,为了防止污染数据库表,让大家看起来很乱,所以我就使用 map 结构在 service 层进行了数据模拟;“用户管理”和“角色管理”我已经实现了最基础的增加和查询所有的功能,因为这并不是我们学习的重点,所以一些基本的配置和页面编写我就帮大家完成了。

我们正好趁此机会看着下边的页面,以这个项目为基础来进行学习 Spring Security,最终实现的效果就是:

  • zhangsan:作为产品采购员,只能访问产品管理模块
  • lisi:作为财务管理员,只能访问订单管理模块
  • wangwu:作为系统管理员,可以访问所有模块,并可以对 zhangsan 和 lisi 进行访问权限管理

790ce9760d67082eaa74a02d1296517c.png

然后导入数据库,修改数据库连接并运行项目查看

二、Spring Security 的基础使用

2.1 导入所需依赖

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

2.2 创建配置对象

因为我们已经引入了spring-boot-starter-security,默认会帮我们自动配置好 Spring Security 的所有配置,我们要是想要修改默认配置,只要重写里边的指定方法即可。我们创建指定配置对象,用于修改默认配置:com.caochenlei.config.SecurityConfig

@Configuration
@EnableWebSecurity //开启Spring Security对WebMVC的支持
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    //请将对Spring Security的配置方法写在这个类中
}

2.3 使用默认账户

当我们做完以上工作,我们就可以使用 Spring Security 的功能了,当你启动你的项目,在地址栏输入:http://localhost:8080,如果能够正常运行,那么,你将会看到如下界面:

b20e2ffbae4a4dbf38c9407009840c35.png

默认账号:user

默认密码:控制台有

5f82d3b1491e60d60265fd0558368b6b.png

2.4 配置登录用户

我们也看到了,使用 Spring Boot 帮我们配置好的账户和密码,难免有些不方便,我们如何自己指定用户和密码以及用户和密码所对应的角色呢,那么就需要用到配置类了,在配置类中,加入下边这段代码。

@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
    // super.configure(auth);
    auth.inMemoryAuthentication().withUser("user").password("{noop}123456").roles("USER");
    auth.inMemoryAuthentication().withUser("admin").password("{noop}123456").roles("ADMIN");
}

这里一定要注意,角色前边千万不能加前缀 ROLE_,否则你会连工程都起不来,这是规定。重新启动项目,访问项目首页,试试新配制的用户和密码好不好用。

2.5 退出当前登录

如果想要注销,只要在浏览器地址访问:http://localhost:8080/logout 就可以了,为了功能完整,请你打开 main.html,第16行,修改注销地址为以下这段代码:

<ul class="navbar-nav px-3">
    <li class="nav-item text-nowrap">
        <a class="btn btn-danger btn-sm" th:href="@{/logout}">注销</a>
    </li>
</ul>

2.6 开放内嵌框架

当你使用用户 user 密码 123456 登录的时候,默认就会进入到权限管理系统的后台首页,但是当你点击各个功能模块的时候,会发现 localhost 拒绝了我们的连接请求。其实这个问题还是挺常见的一个问题,项目中如果用到 iframe 嵌入网页,然后用到 Spring Security,请求就会被拦截,如果你打开 F12 开发者控制台,你可能就会发现这样一句报错:Refused to display 'http://localhost:8080/user/add' in a frame because it set 'X-Frame-Options' to 'deny'.

916e03de47c3591123e2616a1b9a560a.png

Spring Security 下,X-Frame-Options 默认为 DENY,非 Spring Security 环境下,X-Frame-Options 的默认大多也是 DENY,这种情况下,浏览器拒绝当前页面加载任何Frame页面,设置含义如下:

  • DENY:浏览器拒绝当前页面加载任何 frame 页面
  • SAMEORIGIN:frame 页面的地址只能为同源域名下的页面
  • ALLOW-FROM:origin为允许frame加载的页面地址

既然清楚了问题的来源,那我们也就好解决这个问题了,有两种解决办法,第一种就是我们关掉 Spring Security 对 frame 的拦截;

另外一种就是将 X-Frame-Options 设置为 SAMEORIGIN,也就是只能是我们同域名下的请求访问,当然了,这种拦截机制肯定是为了保证系统的安全性,如果关掉了,有点太可惜了,我在这里给出两种解决方案的配置,但是我会采用第二种,而不是第一种的关闭。

第一种:

@Override
protected void configure(HttpSecurity http) throws Exception {
    //关闭X-Frame-Options响应头
    http.headers().frameOptions().disable();
}

第二种:

@Override
protected void configure(HttpSecurity http) throws Exception {
    //设置X-Frame-Options响应头为SAMEORIGIN
    http.headers().frameOptions().sameOrigin();
}

2.7 指定登录界面

虽然默认的登录页面还不错,往往项目中的静态页面已经是前端开发好的,包括登录页面,我们想要使用自己的登录界面该怎么办?我们不妨转换一下思维,使用自带的页面,我们先打开源码,看看他是怎么写的,按照他的这个模式,我们模仿着写到自己的登录界面中不就好了,为了节约大家的时间,我就在下边贴出来了关键部分,你也可以自己打开尝试,如下所示:

178e52304b07d3368d12f7e3ed8be4bd.png

我们会发现,他的这个登录页面没有什么特别的,就是一个 form 表单,里边有两个文本框,一个是账号,一个是密码,还有最下边多了一个特殊的 hidden 隐藏域,这个隐藏域他是为了防止 csrf 跨站破坏的,这个值每一次启动项目都不一样,是一个动态值,他是为了标识当前请求一定是我们自己的请求,而不是别的网站仿造的请求,我们的所有请求都需要携带上这个标签上边的 value 值,我们也称这个值为 token 值,如果使用的是 thymeleaf,那么 form action 会帮我们自动加上 csrf 隐藏域,这样我们不用什么特殊处理也就可以登录了,我们找到我们工程中的 login.html,里边是一个空的 html,请把以下代码复制进入。下边是我们自己定义的一个登录页面。

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <title>自定义登录页</title>
    <link rel="stylesheet" th:href="@{css/bootstrap.min.css}">
</head>
<body>
<div class="container mt-4">
    <form th:action="@{/login}" method="post">
        <div class="form-group">
            <label for="username">用户:</label>
            <input type="text" class="form-control" id="username" name="username" placeholder="请输入用户" required>
        </div>
        <div class="form-group">
            <label for="password">密码:</label>
            <input type="text" class="form-control" id="password" name="password" placeholder="请输入密码" required>
        </div>
        <div class="form-group form-check">
            <input type="checkbox" class="form-check-input" id="autoLogin">
            <label class="form-check-label" for="autoLogin">自动登录</label>
        </div>
        <button type="submit" class="btn btn-primary">登录</button>
    </form>
</div>
<script th:src="@{js/jquery-3.5.1.min.js}"></script>
<script th:src="@{js/bootstrap.bundle.min.js}"></script>
</body>
</html>

我们编写好自己的登录页面,还得需要告诉 Spring Security 你登录的时候不要使用你自己的登录界面了使用我的,我们只需要在配置对象中编写如下配置即可,然后重新启动项目,我们来看一看效果,是不是可以了。

@Override
protected void configure(HttpSecurity http) throws Exception {
    //设置X-Frame-Options响应头为SAMEORIGIN
    http.headers().frameOptions().sameOrigin();
    //放行不用权限的资源(去登录页面当然不需要用权限,否则你都看不到登录界面,还怎么登录,所以去登录界面必须放行)
    http.authorizeRequests().antMatchers("/toLogin").permitAll();
    //拦截需要权限的资源(拦截所有请求,要想访问,登录的账号必须拥有USER和ADMIN的角色才行)
    http.authorizeRequests().antMatchers("/**").hasAnyRole("USER", "ADMIN").anyRequest().authenticated();
    //设置自定义登录界面
    http.formLogin() //启用表单登录
        .loginPage("/toLogin") //登录页面地址,只要你还没登录,默认就会来到这里
        .loginProcessingUrl("/login") //登录处理程序,Spring Security内置控制器方法
        .usernameParameter("username") //登录表单form中用户名输入框input的name名,不修改的话默认是username
        .passwordParameter("password") //登录表单form中密码框输入框input的name名,不修改的话默认是password
        .defaultSuccessUrl("/main") //登录认证成功后默认转跳的路径
        //.successForwardUrl("/main") //登录成功跳转地址,使用的是请求转发
        .failureForwardUrl("/toLogin")//登录失败跳转地址,使用的是请求转发
        .permitAll();
}
@Controller
public class MainController {
    @RequestMapping("/main")
    public String main() {
        return "main";
    }

    //跳转到登录页的方法
    @RequestMapping("/toLogin")
    public String toLogin() {
        return "login";
    }
}

2.8 开放静态资源

最终我们启动后,发现确实来到了我们自己定义的登录页面了,说明我们之前的配置没有任何问题,但是,好像干干巴巴,啥样式都没有,这是为什么呢?如果你能看到样式,你清理一下浏览器缓存或者 CTRL+F5 强制刷新一下,就看不到了,至于原因,我们不难想到,刚才我们只是对跳转到登录页的请求进行了放行,而 Spring Security 默认是拦截所有请求,那肯定也包括静态资源 css、js、img 之类的,因此,静态资源是应该要被放行的,静态资源是不需要进行保护的,我们需要在 SecurityConfig 配置如下代码来放行静态资源。

@Override
public void configure(WebSecurity web) throws Exception {
    web.ignoring().antMatchers("/css/**");
    web.ignoring().antMatchers("/img/**");
    web.ignoring().antMatchers("/js/**");
    web.ignoring().antMatchers("/favicon.ico");
}

06255a6b3817e32d40dfbb4d9fa9d12e.png

2.9 指定退出页面

当你现在想要退出登录,点击右上角咱们之前配置好的注销,你就会神奇的发现,好像不能退出了,这是因为,默认退出会直接跳转到 /login 自动生成的认证页面,现在,认证页面也就是登录页面,已经改成我们自己的登录页面了,你只要指定了登录页面了,那默认的登录页面自然就不会创建了,因此当你退出的时候也就会报 404 找不到异常。

而我们想要解决这个问题,其实很简单,我们给退出指定一个退出页面,只需要加入以下这段配置,很类似我们配置登录页的时候的代码:

@Override
protected void configure(HttpSecurity http) throws Exception {
    ...
    ...
    //设置自定义登出界面
    http.logout()//启用退出登录
        .logoutUrl("/logout")//退出处理程序,Spring Security内置控制器方法
        .logoutSuccessUrl("/toLogin")//退出成功跳转地址
        .invalidateHttpSession(true)//清除当前会话
        .deleteCookies("JSESSIONID")//删除当前Cookie
        .permitAll();
}

即使加上了指定退出页的配置,当你登录后,点击注销,还是报 404 找不到资源,如果大家是跟着一步一步走来的,那就应该见过下边这个页面,当你在地址栏也好,还是 a 标签中也好,只要请求路径是:http://localhost:8080/logout,你就会看见下边这个是否确认注销的页面,你输入的刚才那个请求并不是真正退出,他还会问你是不是要退出,只有当你点击了这个 Log Out,才是真正退出,你看他的源码,他是向 /logout 发送了一个 post 请求,并且还携带了 csrf 这个隐藏域,那我们是不是就可以仿照他这种形式,修改一下我们自己的退出功能呢。

83197672154dddbeaf60d9e9974b6941.png

找到 main.html,把之前的 a 标签的 get 请求,换成 form 的 post 请求,并加上隐藏域 csrf,csrf 不用我们自己加,只要你是用的thymeleaf 的 form,他会帮我们加上,不信的话,可以启动工程,右键查看源代码,看看是不是会自动生成一个 csrf 隐藏域。

<ul class="navbar-nav px-3">
    <li class="nav-item text-nowrap">
        <form th:action="@{/logout}" method="post">
            <input class="btn btn-danger btn-sm" type="submit" value="退出">
        </form>
    </li>
</ul>

三、Spring Security 的高级使用

3.1 深入跨站请求伪造

3.1.1 什么是 CSRF

CSRF

3.1.2 form 表单如何添加 token

如果使用的是 thymeleaf,那么 form action 会帮我们自动加上 csrf 隐藏域,我们不用特殊处理。

如果自己想要设置,我们也可以使用隐藏域自己设置,一般我们不会设置这个,默认就有你设置他干啥,参考代码如下:

<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">

3.1.3 ajax 请求如何添加 token

如果您使用的是 thymeleaf,则可以直接在 head 标签内加上一个隐藏域即可。

<meta name="_csrf" th:content="${_csrf.token}"/>
<meta name="_csrf_header" th:content="${_csrf.headerName}"/>
$(function () {
    var token = $("meta[name='_csrf']").attr("content");
    var header = $("meta[name='_csrf_header']").attr("content");
    $(document).ajaxSend(function(e, xhr, options) {
        xhr.setRequestHeader(header, token);
    });
});

3.1.4 文件上传避免 CSRF 拦截

请将 MultipartFilter 在 Spring Security 过滤器之前指定。MultipartFilter 在 Spring Security 过滤器之前指定,这意味着任何人都可以在您的服务器上放置临时文件。但是,只有授权用户才能提交由您的应用程序所处理的文件。通常,这是推荐的方法,因为临时文件上传对大多数服务器的影响可以忽略不计。具体配置代码如下:

public class SecurityApplicationInitializer extends AbstractSecurityWebApplicationInitializer {
    @Override
    protected void beforeSpringSecurityFilterChain(ServletContext servletContext) {
        insertFilters(servletContext, new MultipartFilter());
    }
}

3.1.5 如何关闭 CSRF 防御机制

@Override
protected void configure(HttpSecurity http) throws Exception {
    ...
    ...
    //关闭CSRF跨站点请求仿造保护
    http.csrf().disable();
}

3.2 自动登录

如果我想要关闭浏览器,下次再打开浏览器,权限管理系统会自动根据我上次的登录状态进行登录,这就是登录常用的“自动登录功能”,要想实现自动登录功能,我们需要实现两处关键配置就能使用了,具体操作如下:

打开 login.html 修改自动登录的 name 为 remember-me,这是一个默认名称,可以修改,但是一般我们就叫这个名

<div class="form-group form-check">
    <input type="checkbox" class="form-check-input" id="autoLogin" name="remember-me">
    <label class="form-check-label" for="autoLogin">自动登录</label>
</div>

配置 SecurityConfig 开启自动登录功能

@Override
protected void configure(HttpSecurity http) throws Exception {
    ...
    ...
    //开启记住我功能(自动登录)
    http.rememberMe()
        .rememberMeParameter("remember-me")//表单参数名,默认参数是remember-me
        .rememberMeCookieName("remember-me")//浏览器存的cookie名,默认是remember-me
        .tokenRepository(new InMemoryTokenRepositoryImpl()) // 内存实现
        .tokenValiditySeconds(60*60*24*30);//保存30两天,默认是两周
}

打开 http://localhost:8080/ ,登录以后,我们在关闭浏览器,然后重新打开 http://localhost:8080/ ,发现仍然可以访问,并且这时候不需要登录,他是怎么做到的呢?其实,在登录成功以后会往当前网站的 cookie 中写入一个自动登录的 token 值,当我们下次启动的时候,只要这个cookie没有消失,Spring Security 就能拿到这个 cookie 的中保存的 token 的值,然后帮我们自动登录认证。

a4d8a08088701d7cc390fd29b0e814c4.png

3.3 保存凭据到数据库

自动登录功能方便是大家看得见的,但是安全性却令人担忧。因为 cookie 毕竟是保存在客户端的,很容易盗取,而且 cookie的值还与用户名、密码这些敏感数据相关,虽然加密了,但是将敏感信息存在客户端,还是不太安全。那么这就要提醒喜欢使用此功能的,用完网站要及时手动退出登录,清空认证信息。 此外,Spring Security 还提供了 remember-me 的另一种相对更安全的实现机制:在客户端的 cookie 中,仅保存一个无意义的加密串(与用户名、密码等敏感数据无关),然后在数据库中保存该加密串-用户信息的对应关系,自动登录时,用 cookie 中的加密串,到数据库表中验证,如果通过,自动登录才算通过。这样,自动登录功能的安全性就有了保证,因此,我们需要在数据库中创建一张用于保存自动登录信息的表,这张表是固定的,包括名称、字段等信息,都不能修改,否则会认识失败。

CREATE TABLE `persistent_logins` (
`username` varchar(64) NOT NULL,
`series` varchar(64) NOT NULL,
`token` varchar(64) NOT NULL,
`last_used` timestamp NOT NULL,
PRIMARY KEY (`series`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

接下来,我们需要配置一下,告诉 Spring Security 使用哪一个 dataSource 来操作这个表

@Override
protected void configure(HttpSecurity http) throws Exception {
    ...
    ...
    //开启记住我功能(自动登录)
    http.rememberMe()
        .rememberMeParameter("remember-me")//表单参数名,默认参数是remember-me
        .rememberMeCookieName("remember-me")//浏览器存的cookie名,默认是remember-me
        .tokenValiditySeconds(60 * 60 * 24 * 30)//保存30两天,默认是两周
        .tokenRepository(persistentTokenRepository());//使用数据库存储token,防止重启服务器丢失数据,非常重要,没有他不能保存到数据库
}

//数据源是咱们默认配置的数据源,直接注入进来就行
@Autowired
private DataSource dataSource;

@Bean
public PersistentTokenRepository persistentTokenRepository() {
    JdbcTokenRepositoryImpl jdbcTokenRepository = new JdbcTokenRepositoryImpl();
    jdbcTokenRepository.setDataSource(dataSource);
    return jdbcTokenRepository;
}

接下来,我们重新进行测试,发现也是可行的,并且这里给出了浏览器和数据库的截图信息:

649db38bbef907728eff514d7cab9723.png

19e87dc799012a8c11bf29e2f8d79522.png

3.4 展示当前登录用户

登录成功以后,如何显示出来当前登录成功的用户名呢?我们这里给出两种常用方法,他们都必须使用 Spring Security 的标签库,在使用 thymeleaf 渲染前端的 html 时,thymeleaf 为 SpringSecurity 提供的标签属性,首先需要引入 thymeleaf-extras-springsecurity5 依赖支持。

(1) 在pom 文件中的引入springsecurity的标签依赖thymeleaf-extras-springsecurity5。

<dependency>
  <groupId>org.thymeleaf.extras</groupId>
  <artifactId>thymeleaf-extras-springsecurity5</artifactId>
</dependency>

(2) 在 main.html 文件里面导入标签所对应的名称空间。

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"
      xmlns:sec="http://www.thymeleaf.org/thymeleaf-extras-springsecurity5">

第一种:打开 main.html , 修改

<a class="navbar-brand col-md-3 col-lg-2 mr-0 px-3" href="#">
    权限管理系统,您好:
    <span sec:authentication="principal.username"></span>
</a>

第二种:打开 main.html, 修改

<a class="navbar-brand col-md-3 col-lg-2 mr-0 px-3" href="#">
    权限管理系统,您好:
    <span sec:authentication="name"></span>
</a>

修改完成,看看页面能够显示当前用户:

c71deb43035e559c18d6c21fd2873fad.png

3.5 对接数据库中数据

我们现在已经在内存中(代码写死的就在内存中)配置好了两个用户(user、admin)以及他们所对应的角色,但是,在真实的企业开发中,这些信息显然是不能保存在配置文件中的,因为要动态添加删除用户以及角色,我们就需要使用数据库来保存,现在 Spring Security 默认是走的配置对象中的账户和密码,我们如何对接数据库中的数据呢?

第一步:实现自己的 SysUserDetailsService 接口继承 UserDetailsService

public interface SysUserDetailsService extends UserDetailsService {   
}

第二步:实现自己的 SysUserDetailsService 接口的 loadUserByUsername 方法,方法传入一个字符串,代表当前登录的用户名

@Service
@Transactional
public class SysUserDetailsServiceImpl implements SysUserDetailsService {
    @Autowired
    private SysUserMapper sysUserMapper;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        // 根据用户名去数据库中查询指定用户,这就要保证数据库中的用户的名称必须唯一,否则将会报错
        SysUser sysUser = sysUserMapper.findUserByUsername(username);
        // 如果没有查询到这个用户,说明数据库中不存在此用户,认证失败
        if (sysUser == null) {
            throw new UsernameNotFoundException("user not exist");
        }

        // 获取该用户所对应的所有角色,当查询用户的时候级联查询其所关联的所有角色,用户与角色是多对多关系
        // 如果这个用户没有所对应的角色,也就是一个空集合,那么在登录的时候会报 403 没有权限异常,切记这点
        List<SimpleGrantedAuthority> authorities = new ArrayList<>();
        List<SysRole> sysRoles = sysUser.getSysRoles();
        for (SysRole sysRole : sysRoles) {
            authorities.add(new SimpleGrantedAuthority(sysRole.getName()));
        }

        // 最终需要返回一个 SpringSecurity 的 UserDetails 对象,{noop}表示不加密认证
        // org.springframework.security.core.userdetails.User 实现了 UserDetails 对象,是 SpringSecurity 内置认证对象
        return new User(sysUser.getUsername(), "{noop}"+sysUser.getPassword(), authorities);
    }
}

第三步:修改配置文件 SecurityConfig 中的认证提供者换成咱们自己定义的,然后重新启动权限管理系统使用数据库中的账户登录即可。

@Autowired
private SysUserDetailsService sysUserDetailsServiceImpl;

@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
    auth.userDetailsService(sysUserDetailsServiceImpl);
}

第四步:使用数据库所提供的账户进行登录测试。

3.6 用户密码进行加密

第一步:配置加密对象,然后设置给咱们自己的认证提供者。

@SpringBootApplication
public class SpringBootSecurityApplication {

    public static void main(String[] args) {
        SpringApplication.run(SpringBootSecurityApplication.class, args);
    }

    @Bean
    public BCryptPasswordEncoder passwordEncoder(){
        return new BCryptPasswordEncoder();
    }
}
@Autowired
private SysUserDetailsService sysUserDetailsServiceImpl;

@Autowired
private BCryptPasswordEncoder passwordEncoder;

@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
    auth.userDetailsService(sysUserDetailsServiceImpl).passwordEncoder(passwordEncoder);
}

第二步:保存用户的时候,给用户的密码进行加密,修改SysUserServiceImpl

@Autowired
private BCryptPasswordEncoder passwordEncoder;

@Override
public void save(SysUser sysUser) {
    sysUser.setPassword(passwordEncoder.encode(sysUser.getPassword()));
    sysUserMapper.save(sysUser);
}

第三步:去掉 SysUserDetailsServiceImpl 中的 {noop}

@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
    ...
    ...
    //最终需要返回一个SpringSecurity的UserDetails对象,{noop}表示不加密认证
    //org.springframework.security.core.userdetails.User实现了UserDetails对象,是SpringSecurity内置认证对象
    return new User(sysUser.getUsername(), sysUser.getPassword(), authorities);
}

第四步:手动修改数据库中的密码为加密后的密码,我们现在需要知道 123456 加密后的密文,需要手动生成,注意啊,每一次生成都不一样,但是都可以用

public class CreatePwd {
    public static void main(String[] args) {
        BCryptPasswordEncoder bCryptPasswordEncoder = new BCryptPasswordEncoder();
        String encode = bCryptPasswordEncoder.encode("123456");
        System.out.println(encode);
    }
}

56dadc76c73308e85418c04f25059e1e.png

第五步:重新启动权限管理系统,分别使用 zhangsan、lisi、wangwu 进行登录测试,发现都可以正常进行登录,我在创建表的时候默认就给他们分配了权限。

但是注意:前方高能,你能够登录,但是肯定你点击左侧的菜单右侧会报 403 没有权限,还记得之前我说过,咱们在配置类中拦截的所有资源所对应的角色是不能带前缀 ROLE 否则会出问题,这是框架内部的一个机制,你打开数据库,会发现目前所有的角色都是没有 ROLE 前缀的,那不是对的吗,出错就出错在这里,在你对接数据库的时候,权限校验的时候,他会默认给你定义的角色加上 ROLE 前缀,因此,你就应该知道,你为什么没有权限了,数据库中的角色可没有 ROLE,解决的方法就是给所有角色都加上前缀 ROLE,加完以后,你数据库中的效果应该如下:

59256b88e023ce072fa5d8260d0e5c23.png

修改完成以后,重新启动,然后分别登录,你将会看到如下截图:

zhangsan:用户权限和产品权限

867f83f0acd743ee1317d1b297e1681c.png

lisi:用户权限和订单权限

b68bb5b06e2c790e8eb2020f9d7c5da5.png

wangwu:所有权限

74bc0e94256a8f81a2113d3ea02bad7e.png

为什么这三个账户都能登录成功,以下几个方面很重要:

  1. Spring Security 已经配置了加密登录,我们手动把数据库中的 123456 改成了密文,登录的时候才可以保证认证成功。

  2. 认证成功了可不一定能访问我们系统的资源,必须拥有相对应的角色,虽然角色是我们自己定义的,但是请你不要忘记,我们一开始,使用死的配置进行配置用户的时候,那个时候,只有拥有 USER 和 ADMIN 这样的用户才能访问系统资源,这就是为什么 zhangsan 和 lisi 必须要有 USER 角色了。

    //拦截需要权限的资源
    http.authorizeRequests().antMatchers("/**").hasAnyRole("USER", "ADMIN").anyRequest().authenticated();
    

3.7 动态展示功能菜单

3.7.1 页面菜单动态展示

细心的你应该发现了,无论是 zhangsan、lisi、wangwu 中的哪一个人登录进去,左侧的菜单都是下边这个样子,完全没有实现我们的效果

e5df3c828a635b3281ed1b4cff03fd85.png

我们可以使用 Spring Security 提供的标签库来动态判断,只有拥有指定角色的人,才可以访问我们指定的功能模块,具体做法如下,找到 main.html 进行修改:

<ul class="nav flex-column">
    <li class="nav-item border-bottom" sec:authorize="hasAnyRole('ROLE_ADMIN', 'ROLE_PRODUCT')">
        <p><a href="#">产品管理</a></p>
        <ul>
            <li><a th:href="@{product/add}" target="container">添加产品</a></li>
            <li><a th:href="@{product/findAll}" target="container">产品列表</a></li>
        </ul>
    </li>
    <li class="nav-item border-bottom" sec:authorize="hasAnyRole('ROLE_ADMIN', 'ROLE_ORDER')">
        <p><a href="#">订单管理</a></p>
        <ul>
            <li><a th:href="@{order/add}" target="container">添加订单</a></li>
            <li><a th:href="@{order/findAll}" target="container">订单列表</a></li>
        </ul>
    </li>
    <li class="nav-item border-bottom" sec:authorize="hasAnyRole('ROLE_ADMIN')">
        <p><a href="#">用户管理</a></p>
        <ul>
            <li><a th:href="@{user/add}" target="container">添加用户</a></li>
            <li><a th:href="@{user/findAll}" target="container">用户列表</a></li>
        </ul>
    </li>
    <li class="nav-item border-bottom" sec:authorize="hasAnyRole('ROLE_ADMIN')">
        <p><a href="#">角色管理</a></p>
        <ul>
            <li><a th:href="@{role/add}" target="container">添加角色</a></li>
            <li><a th:href="@{role/findAll}" target="container">角色列表</a></li>
        </ul>
    </li>
</ul>

我们保存以后,重新启动权限管理系统,再次分别登录zhangsan、lisi、wangwu,看看左侧菜单栏发生了什么变化

zhangsan:

c03ae469d32b07d5a49e67e055212d63.png

Lisi:

2fdbd09e58ad17bcadcde41f853b8b91.png

Wangwu:

e012f49a0c028b681be8b5e6609959c0.png

3.7.2 业务代码动态拦截

我们发现虽然界面上效果好像可以了,但是,难道就真的可以了吗?还有没有什么纰漏,我们假设一种场景,一个程序员,它使用 zhangsan 的账户登录系统后,闲来无事,他呢,自己又懂技术,想试试,在地址栏直接输入李四的订单页面,看看能不能进去,结果发现,进去了,这就是纰漏。

74eecc71b1baf5aff55cd3db7245c6b8.png

我们上一步所实现的只是表面你所看到的,也就是视图上实现了不同用户可以看到不同的菜单,但是在控制器层并没有拦截住,这就是导致问题的根本原因,一般我们的解决办法就是在业务层(控制器层也可以,但是不推荐),给相对应的方法或者相应的类添加角色判断注解,只有拥有相应角色的用户才能访问该方法或者该类,在 Spring Security 中,一共支持三种注解都可以做到这个效果,而这三种注解的开启都是一个注解上进行开启,我接下来会把三个注解都打开,只使用第一种注解,其余两种会给大家注释掉,要记住,打开的哪个注解,就用哪个注解来限制访问,必须配套使用。这里演示三类注解,实际开发中,用一类即可!

在主启动类上添加以下配置

@SpringBootApplication
//三种任选其一,不必全开,全开也没事,一定要注意标签的对应关系
@EnableGlobalMethodSecurity(
        jsr250Enabled = true, //JSR-250注解
        prePostEnabled = true, //spring表达式注解
        securedEnabled = true //SpringSecurity注解,推荐使用
)
public class SpringBootSecurityApplication {
    public static void main(String[] args) {
        SpringApplication.run(SpringBootSecurityApplication.class, args);
    }

    @Bean
    public BCryptPasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
}

修改 OrderServiceImpl :我们就以这个类为例进行讲解,其余剩下的所有的实现都需要标注,可以在方法上标注注解,也可以在类上标注注解

@Service
@Transactional
public class OrderServiceImpl implements OrderService {
    ...
    ...

    @RolesAllowed({"ROLE_ADMIN", "ROLE_ORDER"})//JSR-250注解
    //@PreAuthorize("hasAnyRole('ROLE_ADMIN','ROLE_ORDER')")//spring表达式注解
    //@Secured({"ROLE_ADMIN", "ROLE_ORDER"})//SpringSecurity注解
    @Override
    public void save(Order Order) {
        int size = orderMap.size();
        int id = ++size;
        Order.setId(id);
        orderMap.put(id, Order);
    }

    @RolesAllowed({"ROLE_ADMIN", "ROLE_ORDER"})//JSR-250注解
    //@PreAuthorize("hasAnyRole('ROLE_ADMIN','ROLE_ORDER')")//spring表达式注解
    //@Secured({"ROLE_ADMIN", "ROLE_ORDER"})//SpringSecurity注解
    @Override
    public List<Order> findAll() {
        Collection<Order> Orders = orderMap.values();
        return new ArrayList<>(Orders);
    }
}

完成以后,重新启动权限管理系统,然后登录 zhangsan,你再次输入 lisi 的添加订单地址,看看还能不能访问,你会发现,添加订单界面还在,但是当你点击提交挺订单的时候,就会 403 权限不足,如果你连界面都不想展示出来,请你在控制层上标注相对应的注解即可。

ca8414ef74d4dd5a4085cd961addbb7e.png

3.8 权限不足异常处理

大家也发现了,每次权限不足都出现 403 页面,这个错误页面是 Spring Boot 自己生成的白页,非常的难看,很不友好,当出现 403 异常以后,如何跳转到我们自定义的页面,接下来,我将提供两种形式来解决,

  • 第一种是 Spring Security 提供的解决方式
  • 第二种是 Spring MVC 提供的解决方式

在解决问题之前,我们先定义自己的 403 没有权限的页面,以及通过控制器方法跳转到 403.html,以上这几种情况还可以配置 404、500 等错误页面的跳转,如有需要也可以自行配置。

以下几种方法任选其一使用即可,不必全部配置,推荐使用第二种 Spring MVC 提供的异常处理机制。

第一种:SecurityConfig中配置一下代码即可

@Override
protected void configure(HttpSecurity http) throws Exception {
    ...
    ...   

    //异常处理,使用函数表达式的写法可以不用在单独写一个类,非常方便
    http.exceptionHandling()
        .accessDeniedHandler((request, response, ex) -> {
            response.setStatus(HttpServletResponse.SC_FORBIDDEN);
            response.setHeader("Content-Type", "application/json;charset=utf-8");
            PrintWriter out = response.getWriter();
            out.write("{\"status\":\"error\",\"msg\":\"权限不足,请联系管理员!\"}");
            out.flush();
            out.close();
        });
}

afaaa94b2ad21d9217d8183bd0f61dd0.png

第二种:

在 templates 目录中创建 error 目录,在 error 目录中创建 403.html

<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <title>没有权限</title>
</head>
<body>
<h3>403,没有权限</h3>
</body>
</html>

MainController 中添加跳转方法,代码如下:

//跳转到错误页的方法
@RequestMapping("/to403")
public String to403() {
    return "error/403";
}

com.caochenlei.controller 中创建一个包 advice ,然后创建 ExceptionAdvice

@ControllerAdvice
public class ExceptionAdvice {
    //别导错类了:org.springframework.security.access.AccessDeniedException
    //只有出现AccessDeniedException异常才调转403.html页面
    @ExceptionHandler(AccessDeniedException.class)
    public String exceptionAdvice() {
        return "forward:/to403";
    }
}

b1af9f142cd742db2ca959ba2c0cd262.png

3.9 保证当前登录人数

有时候我们为了安全,也可以设置同一个账户,只能同时有一个人在线,我们只需要简单的配置就能实现。

第一种:单用户登录,如果有一个登录了,同一个用户在其他地方登录将前一个剔除下线

@Override
protected void configure(HttpSecurity http) throws Exception {
    ...
    ...

    //单用户登录,如果有一个登录了,同一个用户在其他地方登录将前一个剔除下线
    http.sessionManagement().maximumSessions(1).expiredUrl("/toLogin");
}

第二种:单用户登录,如果有一个登录了,同一个用户在其他地方不能登录

@Override
protected void configure(HttpSecurity http) throws Exception {
    ...
    ...
    //单用户登录,如果有一个登录了,同一个用户在其他地方不能登录
    http.sessionManagement().maximumSessions(1).maxSessionsPreventsLogin(true);
}

3.10 开启或关闭 CORS

CORS 是一个 W3C 标准,全称是"跨域资源共享"(Cross-origin resource sharing)。它允许浏览器向跨源(协议 + 域名 + 端口)服务器,发出 XMLHttpRequest 请求,从而克服了 AJAX 只能同源使用的限制。CORS 需要浏览器和服务器同时支持。它的通信过程,都是浏览器自动完成,不需要用户参与。对于开发者来说,CORS 通信与同源的 AJAX 通信没有差别,代码完全一样。浏览器一旦发现 AJAX 请求跨源,就会自动添加一些附加的头信息,有时还会多出一次附加的请求,但用户不会有感觉。因此,实现 CORS 通信的关键是服务器。只要服务器实现了对 CORS 的支持,就可以跨源通信。

开启 CORS

@Override
protected void configure(HttpSecurity http) throws Exception {
    ...
    ...
    //开启CORS
    http.cors();
}

关闭 CORS

@Override
protected void configure(HttpSecurity http) throws Exception {
    ...
    ...
    //关闭CORS
    http.cors().disable();
}

四、SSM 集成 Spring Security

示例代码:SpringSecurity.zip

4.1 基础配置

pom.xml

<dependencies>
    <dependency>
        <groupId>org.springframework.security</groupId>
        <artifactId>spring-security-web</artifactId>
        <version>5.0.1.RELEASE</version>
    </dependency>
    <dependency>
        <groupId>org.springframework.security</groupId>
        <artifactId>spring-security-config</artifactId>
        <version>5.0.1.RELEASE</version>
    </dependency>
</dependencies>

Web.xml

<!-- 配置加载类路径的配置文件 -->
<context-param>
    <param-name>contextConfigLocation</param-name>
    <param-value>classpath:applicationContext.xml,classpath:spring-security.xml</param-value>
</context-param>
<filter>
    <filter-name>springSecurityFilterChain</filter-name>
    <filter-class>org.springframework.web.filter.DelegatingFilterProxy</filter-class>
</filter>
<filter-mapping>
    <filter-name>springSecurityFilterChain</filter-name>
    <url-pattern>/*</url-pattern>
</filter-mapping>

4.2 Spring-security

认证授权由 spring-security 来控制 controller 层,我们写 service 和 dao 层就好

如果我们不自己 写登录界面,可以使用框架默认给我们提供的登录界面

<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
       xmlns:security="http://www.springframework.org/schema/security"
       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
       xsi:schemaLocation="http://www.springframework.org/schema/beans
    http://www.springframework.org/schema/beans/spring-beans.xsd
    http://www.springframework.org/schema/security
    http://www.springframework.org/schema/security/spring-security.xsd">

    <!-- 开启方法级权限控制注解使用,稍后讲解 -->
    <security:global-method-security pre-post-annotations="enabled" jsr250-annotations="enabled"
                                     secured-annotations="enabled"/>

    <!-- 配置不拦截的资源 -->
    <security:http pattern="/login.jsp" security="none"/>
    <security:http pattern="/failer.jsp" security="none"/>
    <security:http pattern="/css/**" security="none"/>
    <security:http pattern="/img/**" security="none"/>
    <security:http pattern="/plugins/**" security="none"/>

    <!--
        配置具体的规则
        auto-config="true"	不用自己编写登录的页面,框架提供默认登录页面
        use-expressions="false"	是否使用SPEL表达式
    -->
    <security:http auto-config="true" use-expressions="true">
        <!-- 配置具体的拦截的规则 pattern="请求路径的规则" access="访问系统的人,必须有ROLE_USER的角色" -->
        <security:intercept-url pattern="/**" access="hasAnyRole('ROLE_USER','ROLE_ADMIN')"/>

        <!-- 定义跳转的具体的页面 
            登录界面
            登录url
            默认主页
            登录失败界面
            登录成功界面
        -->
        <security:form-login
                login-page="/login.jsp"
                login-processing-url="/login.do"
                default-target-url="/pages/main.jsp"
                authentication-failure-url="/failer.jsp"
                authentication-success-forward-url="/pages/main.jsp"
        />

        <!-- 关闭跨域请求 -->
        <security:csrf disabled="true"/>
        <!-- 退出 -->
        <security:logout invalidate-session="true" logout-url="/logout.do" logout-success-url="/login.jsp"/>

    </security:http>

    <!-- 配置加密类 -->
    <bean id="passwordEncoder" class="org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder"/>

    <!-- 切换成数据库中的用户名和密码 -->
    <security:authentication-manager>
        <!--   user-service-ref 为实现用户登录的类  -->
        <security:authentication-provider user-service-ref="userService">
            <!-- 配置加密的方式-->
            <security:password-encoder ref="passwordEncoder"/>
        </security:authentication-provider>
    </security:authentication-manager>
    
    <!-- <bean id="webexpressionHandler" class="org.springframework.security.web.access.expression.DefaultWebSecurityExpressionHandler" />-->
    <!-- 提供了入门的方式,在内存中存入用户名和密码
    <security:authentication-manager>
    	<security:authentication-provider>
    		<security:user-service>
    			<security:user name="admin" password="{noop}admin" authorities="ROLE_USER"/>
    		</security:user-service>
    	</security:authentication-provider>
    </security:authentication-manager>
    -->

</beans>

4.3 使用数据库认证

在 Spring Security 中如果想要使用数据进行认证操作,有很多种操作方式,这里我们介绍使用UserDetails、UserDetailsService来完成操作。

UserDetails

public interface UserDetails extends Serializable {
  Collection<? extends GrantedAuthority> getAuthorities();
  String getPassword();
  String getUsername();
  boolean isAccountNonExpired();
  boolean isAccountNonLocked();
  boolean isCredentialsNonExpired();
  boolean isEnabled();
}

UserDetails 是一个接口,我们可以认为 UserDetails 作用是于封装当前进行认证的用户信息,但由于其是一个接口,所以我们可以对其进行实现,也可以使用 Spring Security 提供的一个 UserDetails 的实现类 User 来完成,以下是 User 类的部分代码

public class User implements UserDetails, CredentialsContainer { 
    private String password; 
    private final String username; 
    private final Set<GrantedAuthority> authorities; // 用户所拥有的权限
    private final boolean accountNonExpired; //帐户是否过期 
    private final boolean accountNonLocked; //帐户是否锁定 
    private final boolean credentialsNonExpired; //认证是否过期 
    private final boolean enabled; //帐户是否可用
}

UserDetailsService

public interface UserDetailsService { 
    UserDetails loadUserByUsername(String username) throws UsernameNotFoundException; 
}

我们需要定义一个 service 来实现 UserDetilsService,这个 service 的 loadUserByUsername 返回 UserDetails 的实现类对象 User

看一个例子

public interface IUserService extends UserDetailsService {}
@Service("userService")
@Transactional
public class UserServiceImpl implements IUserService {

    @Autowired
    private IUserDao userDao;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        UserInfo userInfo = null;
        userInfo = userDao.findByUsername(username);
        //处理自己的用户对象封装成UserDetails
        User user = new User(userInfo.getUsername(), userInfo.getPassword(), userInfo.getStatus() != 0, true, true, true, getAuthority(userInfo.getRoles()));
        return user;
    }

    //作用就是返回一个List集合,集合中装入的是角色描述
    public List<SimpleGrantedAuthority> getAuthority(List<Role> roles) {
        List<SimpleGrantedAuthority> list = new ArrayList<>();
        for (Role role : roles) {
            list.add(new SimpleGrantedAuthority("ROLE_" + role.getRoleName()));
        }
        return list;
    }
}

4.4 服务器端方法级权限控制

在服务器端我们可以通过 Spring security 提供的注解对方法来进行权限控制。Spring Security 在方法的权限控制上

支持三种类型的注解,JSR-250 注解、@Secured 注解和支持表达式的注解。

4.4.1 开启注解使用

这三种注解默认都是没有启用的,需要单独通过 global-method-security 元素的对应属性进行启用

配置文件开启

<security:global-method-security pre-post-annotations="enabled" jsr250-annotations="enabled"
                                 secured-annotations="enabled"/>

注解开启

注解开启

@EnableGlobalMethodSecurity :Spring Security 默认是禁用注解的,要想开启注解,需要在继承 WebSecurityConfifigurerAdapter的类上加 @EnableGlobalMethodSecurity 注解,并在该类中将 AuthenticationManager 定义为 Bean。

4.4.2 JSP-250 注解

  • @RolesAllowed 表示访问对应方法时所应该具有的角色,例如 @RolesAllowed({"USER", "ADMIN"})
  • @PermitAll 表示允许所有的角色进行访问,也就是说不进行权限控制
  • @DenyAll 是和 PermitAll 相反的,表示无论什么角色都不能访问

4.4.3 支持表达式注解

  • @PreAuthorize 在方法调用之前,基于表达式的计算结果来限制对方法的访问

    @PreAuthorize("#userId == authentication.principal.userId or hasAuthority(‘ADMIN’)")
    void changePassword(@Param("userId") long userId ){ } 
    // 这里表示在 changePassword 方法执行之前,判断方法参数 userId 的值是否等于 principal 中保存的当前用户的 userId
    // 或者当前用户是否具有 ROLE_ADMIN 权限,两种符合其一,就可以访问该方法。
    
  • @PostAuthorize 允许方法调用,但是如果表达式计算结果为 false ,将抛出一个安全性异常

    @PostAuthorize 
    User getUser("returnObject.userId == authentication.principal.userId or hasPermission(returnObject, 'ADMIN')");
    
  • @PostFilter 允许方法调用,但必须按照表达式来过滤方法的结果

  • @PreFilter 允许方法调用,但必须在进入方法之前过滤输入值

4.4.4 Secured 注解

@Secured 注解标注的方法进行权限控制的支持,其值默认为 disabled。

@Secured("IS_AUTHENTICATED_ANONYMOUSLY") 
public Account readAccount(Long id); 
@Secured("ROLE_TELLER")

4.5 页面标签权限控制

在 jsp 页面中我们可以使用 spring security 提供的权限标签来进行权限控制

4.5.1 导入

maven 导入

<dependency>
    <groupId>org.springframework.security</groupId>
    <artifactId>spring-security-taglibs</artifactId>
    <version>version</version>
</dependency>

页面导入

<%@taglib uri="http://www.springframework.org/security/tags" prefix="security"%>

4.5.2 常用标签

authentication 标签

<security:authentication property="" htmlEscape="" scope="" var=""/>
  • property: 只允许指定 Authentication 所拥有的属性,可以进行属性的级联获取,如“principle.username”,不允许直接通过方法进行调用
  • htmlEscape:表示是否需要将 html 进行转义。默认为 true。
  • scope:与 var 属性一起使用,用于指定存放获取的结果的属性名的作用范围,默认我 pageContext。Jsp 中拥有的作用范围都进行进行指定
  • var: 用于指定一个属性名,这样当获取到了 authentication 的相关信息后会将其以 var 指定的属性名进行存放,默认是存放在 pageConext 中

authorize 标签

authorize 是用来判断普通权限的,通过判断用户是否具有对应的权限而控制其所包含内容的显示

<security:authorize access="" method="" url="" var=""></security:authorize>
  • access: 需要使用表达式来判断权限,当表达式的返回结果为 true 时表示拥有对应的权限
  • method:method 属性是配合 url 属性一起使用的,表示用户应当具有指定 url 指定 method 访问的权限,method 的默认值为GET,可选值为 http 请求的7种方法
  • url:url 表示如果用户拥有访问指定 url 的权限即表示可以显示 authorize 标签包含的内容
  • var:用于指定将权限鉴定的结果存放在 pageContext 的哪个属性中

accesscontrollist

accesscontrollist 标签是用于鉴定 ACL 权限的。其一共定义了三个属性:hasPermission、domainObject 和 var,其中前两个是必须指定的。

<security:accesscontrollist hasPermission="" domainObject="" var=""></security:accesscontrollist>
  • hasPermission:hasPermission 属性用于指定以逗号分隔的权限列表
  • domainObject:domainObject 用于指定对应的域对象
  • var:var则是用以将鉴定的结果以指定的属性名存入pageContext中,以供同一页面的其它地方使用