参考文献:
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 进行访问权限管理
然后导入数据库,修改数据库连接并运行项目查看
二、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
,如果能够正常运行,那么,你将会看到如下界面:
默认账号:user
默认密码:控制台有
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'.
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 指定登录界面
虽然默认的登录页面还不错,往往项目中的静态页面已经是前端开发好的,包括登录页面,我们想要使用自己的登录界面该怎么办?我们不妨转换一下思维,使用自带的页面,我们先打开源码,看看他是怎么写的,按照他的这个模式,我们模仿着写到自己的登录界面中不就好了,为了节约大家的时间,我就在下边贴出来了关键部分,你也可以自己打开尝试,如下所示:
我们会发现,他的这个登录页面没有什么特别的,就是一个 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");
}
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 这个隐藏域,那我们是不是就可以仿照他这种形式,修改一下我们自己的退出功能呢。
找到 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
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 的值,然后帮我们自动登录认证。
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;
}
接下来,我们重新进行测试,发现也是可行的,并且这里给出了浏览器和数据库的截图信息:
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>
修改完成,看看页面能够显示当前用户:
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);
}
}
第五步:重新启动权限管理系统,分别使用 zhangsan、lisi、wangwu 进行登录测试,发现都可以正常进行登录,我在创建表的时候默认就给他们分配了权限。
但是注意:前方高能,你能够登录,但是肯定你点击左侧的菜单右侧会报 403 没有权限,还记得之前我说过,咱们在配置类中拦截的所有资源所对应的角色是不能带前缀 ROLE 否则会出问题,这是框架内部的一个机制,你打开数据库,会发现目前所有的角色都是没有 ROLE 前缀的,那不是对的吗,出错就出错在这里,在你对接数据库的时候,权限校验的时候,他会默认给你定义的角色加上 ROLE 前缀,因此,你就应该知道,你为什么没有权限了,数据库中的角色可没有 ROLE,解决的方法就是给所有角色都加上前缀 ROLE,加完以后,你数据库中的效果应该如下:
修改完成以后,重新启动,然后分别登录,你将会看到如下截图:
zhangsan:用户权限和产品权限
lisi:用户权限和订单权限
wangwu:所有权限
为什么这三个账户都能登录成功,以下几个方面很重要:
-
Spring Security 已经配置了加密登录,我们手动把数据库中的 123456 改成了密文,登录的时候才可以保证认证成功。
-
认证成功了可不一定能访问我们系统的资源,必须拥有相对应的角色,虽然角色是我们自己定义的,但是请你不要忘记,我们一开始,使用死的配置进行配置用户的时候,那个时候,只有拥有 USER 和 ADMIN 这样的用户才能访问系统资源,这就是为什么 zhangsan 和 lisi 必须要有 USER 角色了。
//拦截需要权限的资源 http.authorizeRequests().antMatchers("/**").hasAnyRole("USER", "ADMIN").anyRequest().authenticated();
3.7 动态展示功能菜单
3.7.1 页面菜单动态展示
细心的你应该发现了,无论是 zhangsan、lisi、wangwu 中的哪一个人登录进去,左侧的菜单都是下边这个样子,完全没有实现我们的效果
我们可以使用 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:
Lisi:
Wangwu:
3.7.2 业务代码动态拦截
我们发现虽然界面上效果好像可以了,但是,难道就真的可以了吗?还有没有什么纰漏,我们假设一种场景,一个程序员,它使用 zhangsan 的账户登录系统后,闲来无事,他呢,自己又懂技术,想试试,在地址栏直接输入李四的订单页面,看看能不能进去,结果发现,进去了,这就是纰漏。
我们上一步所实现的只是表面你所看到的,也就是视图上实现了不同用户可以看到不同的菜单,但是在控制器层并没有拦截住,这就是导致问题的根本原因,一般我们的解决办法就是在业务层(控制器层也可以,但是不推荐),给相对应的方法或者相应的类添加角色判断注解,只有拥有相应角色的用户才能访问该方法或者该类,在 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 权限不足,如果你连界面都不想展示出来,请你在控制层上标注相对应的注解即可。
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();
});
}
第二种:
在 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";
}
}
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中,以供同一页面的其它地方使用