注:本文讲述的是Security在单体架构的应用,不支持集群跨域。另外,本文基于前后端不分离,使用的前端模板引擎是Thymeleaf。
第一个依赖是SpringBoot为Security提供的starter依赖,导入后,Security立即生效,会默认生成一个用户名和密码(项目重启后控制台可见),使项目中所有的请求都需要认证。
第二个依赖是thymeleaf模板引擎为支持Security提供的依赖,这个依赖其实不是必须的,下文会简单提一下它的用法。
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-securityartifactId>
dependency>
<dependency>
<groupId>org.thymeleaf.extrasgroupId>
<artifactId>thymeleaf-extras-springsecurity5artifactId>
dependency>
一般我们的项目中已经有了User对象,直接让它实现UserDetails接口即可。UserDetails是Security默认的用户信息存储媒介,它只存储用户名(username)、密码(password)、权限(authorities)和其他一些用户状态,具体如下:
public interface UserDetails extends Serializable {
// 获取授予用户的权限,包括权限级别authority和级别角色role
Collection<? extends GrantedAuthority> getAuthorities();
// 获取用户密码
String getPassword();
// 获取用户名
String getUsername();
// 标识用户的帐户是否已过期,true(未过期)、false(已过期),过期帐户无法验证。
boolean isAccountNonExpired();
// 标识用户是被锁定还是未锁定,true(未锁定),false(锁定),锁定的用户无法进行身份验证。
boolean isAccountNonLocked();
// 标识用户的凭据(密码)是否已过期,true(未过期)、false(已过期),过期的凭据会阻止身份验证。
boolean isCredentialsNonExpired();
// 标识用户是启用还是禁用,true(启用),false(禁用),禁用的用户无法进行身份验证。
boolean isEnabled();
}
通常来说,我们会在自己的User对象中创建authority(权限级别)、role(级别角色)两个属性,用于实现getAuthorities()。当然,如果你的项目很简单,不需要级别角色的定义,只创建authority属性也是可以的。
username和password相信不用我多说了,我们自己的User对象就有这两个属性,它们的get方法就是UserDetails的接口方法。
至于四个boolean类型的接口方法,如果项目中需要这些功能,就相应添加boolean类型的accountNonExpired、accountNonLocked、credentialsNonExpired、enabled属性。如果项目中没有使用的必要,就直接实现这四个方法全部设定为true即可。
@Data
@AllArgsConstructor
@NoArgsConstructor
public class User implements UserDetails {
private Integer id;
// 此处,用户名为email,并不是一定要取名username、password、authority、role,实现UserDetails接口方法时区分好这些字段即可。
// 如果你的User对象还有其他额外的字段,对UserDetails的实现是完全没有影响的,保持它们的原样即可。
private String email;
private String password;
// 注意,此处的Authority和Role是我定义的枚举类,这也是权限字段常用的定义方式。
private Authority authority;
private Role role;
// 账号权限
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
// 此处注意,因为UserDetails把authority和role都放入了authoritie属性中,所以Security规定role前加上级别角色标识符“ROLE_”,以便区分authoritie列表中哪些元素是authority,哪些是role。
// 当然,你也可以把级别角色标识符“ROLE_”定义在枚举类属性中,此处就可以直接传参role.toString()了。
return AuthorityUtils.createAuthorityList(authority.toString(), "ROLE_" + role.toString());
}
// 账号名
@Override
public String getUsername() {
// 因为我们的用户名为email,所以需要额外实现getUsername()方法
// 而getPassword()方法不用实现,因为@Data注解已经帮我们实现
return email;
}
// 账号没有过期
@Override
public boolean isAccountNonExpired() {
return true;
}
// 账号没有锁定
@Override
public boolean isAccountNonLocked() {
return true;
}
// 凭证未过期
@Override
public boolean isCredentialsNonExpired() {
return true;
}
// 账号可用
@Override
public boolean isEnabled() {
return true;
}
}
import com.alibaba.fastjson.annotation.JSONType;
@JSONType(serializeEnumAsJavaBean = true)
public enum Authority implements BaseEnum<Authority, Integer> {
MEMBER(1, "普通成员"),
ADMIN(2, "普通管理员"),
SUPER(3, "超级管理员");
private Integer code;
private String name;
Authority(Integer code, String name){
this.code = code;
this.name = name;
}
@Override
public Integer getCode() {
return code;
}
@Override
public String getName() {
return name;
}
public static Authority getEnum(Integer code){
for (Authority value : Authority.values()) {
if(value.getCode().equals(code)){
return value;
}
}
return null;
}
}
import com.alibaba.fastjson.annotation.JSONType;
@JSONType(serializeEnumAsJavaBean = true)
public enum Role implements BaseEnum<Role, Integer> {
// 如果在枚举类中直接适应Security,直接定义为ROLE_AD、ROLE_HR、ROLE_MD、ROLE_TD即可。
AD(1, "行政部成员"),
HR(2, "人力资源部成员"),
MD(3, "市场部成员"),
TD(3, "技术部成员");
private Integer code;
private String name;
Role(Integer code, String name){
this.code = code;
this.name = name;
}
@Override
public Integer getCode() {
return code;
}
@Override
public String getName() {
return name;
}
public static Role getEnum(Integer code){
for (Role value : Role.values()) {
if(value.getCode().equals(code)){
return value;
}
}
return null;
}
}
关于枚举类的使用可查看我的上一篇文章,枚举类通用接口BaseEnum有讲到它的来源与使用。
同样的,我们的项目中已经有了UserServiceImpl,直接让它再实现UserDetailsService接口即可。UserDetailsService是Security默认的用户信息查询接口,里面只有一个接口方法,如下:
public interface UserDetailsService {
// 参数var1代表用户名,即根据用户名查询UserDetails对象,而我们使用User对象继承了UserDetails,所以相当于查询User对象。
UserDetails loadUserByUsername(String var1) throws UsernameNotFoundException;
}
@Service
public class UserServiceImpl implements UserService, UserDetailsService {
@Resource
private UserDao userDao;
// 同样的,UserServiceImpl中还有大量实现我们自定义的UserService的方法,并不影响对UserDetailsService的实现
@Override
public User selectByEmail(String emails){
return userDao.selectByEmail(email);
}
@Override
public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
// 因为我们的用户名是email,所以就是用email去查询User对象啦
return this.selectByEmail(email);
}
}
关于UserService和UserDao的实现,这里就不用细说了吧!
UserServiceImpl之所以要把selectByEmail()方法单独列出来,是因为loadUserByUsername()方法返回的是UserDetails对象,这个对象只包含了authorities、username、password等属性,这是Security想要的,但不是我们想要的,我们想要的是完整的User对象,selectByEmail返回的正是User对象,下文会讲到它的使用。
这一步就很常规了,既然都涉及到了查询数据库的用户信息,那么,对于数据源的配置当然是不可少的。
spring.datasource.driver-class-name=com.mysql.jdbc.Driver
spring.datasource.url=jdbc:mysql://localhost:3306/test?characterEncoding=utf-8&serverTimezone=Asia/Shanghai
spring.datasource.username=root
spring.datasource.password=123456
注意,这一步信息量可就大了,代码的每一行注释都值得仔细阅读。
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter implements ProjectConstant {
@Autowired
private UserService userService;
@Autowired
private DataSource dataSource;
// 自定义登录失败处理器,下文会给出
@Autowired
private MyFailureHandler failureHandler;
// 自定义登录验证码过滤器,下文会给出
@Autowired
private CaptchaFilter captchaFilter;
// 封装PersistentTokenRepository对象,用于辅助实现基于Cookie和数据库的remember-me记住我功能,下文会讲到。
@Bean
PersistentTokenRepository tokenRepository(){
JdbcTokenRepositoryImpl tokenRepository = new JdbcTokenRepositoryImpl();
tokenRepository.setDataSource(dataSource);
return tokenRepository;
}
@Override
public void configure(WebSecurity web) throws Exception {
// 设置Security忽略静态资源的访问拦截
web.ignoring().antMatchers("/static/**");
}
// 自定义登录验证逻辑
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.authenticationProvider(new AuthenticationProvider() {
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
// 获取前端登录表单传来的username值和password值,Security规定前端的请求参数一定要为”username“和”password“。
String username = authentication.getName();
String password = (String) authentication.getCredentials();
// 使用selectByEmail()方法查询完整的User对象,而不用loadUserByUsername()方法
// 这也是自定义登录验证逻辑的好处,如果采用Security默认的登录逻辑,使用的就是loadUserByUsername()方法
User user = userService.selectByEmail(username);
if(ObjectUtils.isEmpty(user)){
throw new UsernameNotFoundException("用户名不存在!");
}
// 注意,用户注册时我使用了BCryptPasswordEncoder.encode()方法对密码进行了加密,于是,登录验证时也需要使用它验证密码是否正确。
BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
// 注意,再使用BCryptPasswordEncoder.encode()方法对password进行加密,然后使用equals方法进行比较是错误的。
// BCryptPasswordEncoder每次的加密结果都不一样,需使用matches()方法才能验证密码是否正确。
boolean matches = passwordEncoder.matches(password, user.getPassword());
if(!matches){
throw new BadCredentialsException("密码不正确!");
}
// 登录成功后,将完整的User对象,password、authorities交给Security缓存。
// 在业务代码中,我们想要获取当前登录User对象,直接读取Security的缓存数据即可,下文会给出具体的读取方法。
return new UsernamePasswordAuthenticationToken(user, password, user.getAuthorities());
}
// 固定写法,标识项目使用传统的用户名与字符串密码的方式登录,而不是使用第三方扫码登录、人脸识别登录等高级登录方式。
@Override
public boolean supports(Class<?> aClass) {
return UsernamePasswordAuthenticationToken.class.equals(aClass);
}
});
}
// 自定义认证与授权逻辑
@Override
protected void configure(HttpSecurity http) throws Exception {
//加入自定义的验证码过滤器,在用户名密码过滤器之前生效
http.addFilterBefore(captchaFilter, UsernamePasswordAuthenticationFilter.class);
// 指定登录路径,指定登录失败逻辑,loginPage为get访问,loginProcessingUrl为post提交,两者一个指登录页面访问路径,一个指登录表单提交路径,是不一样的。
// defaultSuccessUrl是登录成功后的默认跳转路径,如果用户第一次访问的是登录页面,他登录后将跳转到main页面,如果用户是访问其他页面被拦截到登录页,他登录成功后将回到之前的被拦截页。
// 这一点是Security做得比较好的,这也是不用自定义登录成功逻辑(successHandler)的原因。如果自定义登录成功后的跳转逻辑,还真做不到页面拦截记忆的效果。
http.formLogin().loginPage("/login").loginProcessingUrl("/login").defaultSuccessUrl("/main")
.failureHandler(failureHandler);
// 基于数据库保存记录的记住我功能,30天免登录,共计2592000秒。Security规定前端传递的记住我参数为”remember-me“,boolean型。
http.rememberMe().tokenRepository(tokenRepository()).tokenValiditySeconds(86400 * 30).userDetailsService((UserDetailsService) userService);
// 指定退出登录路径,自定义登出逻辑,注意,最新的SpringSecurity版本已默认登出url为post提交方式,get提交方式不能被Security识别。
http.logout().logoutUrl("/logout")
.logoutSuccessHandler(new LogoutSuccessHandler() {
@Override
public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
response.sendRedirect(request.getContextPath() + "/login");
}
});
// 绑定访问路径与用户权限之间的关系
http.authorizeRequests()
// login、forget(找回密码)、register(注册)、captcha(获取验证码图片)、mail/activate(获取邮箱验证码)等请求不用登录认证
.antMatchers("/login", "/forget", "/register", "/captcha", "/mail/activate",
"/404", "/500").permitAll()
// 普通管理员能访问的功能
.antMatchers("/admin", "/admin/*").hasAuthority(Authority.ADMIN.toString())
// 超级管理员才能访问的功能
.antMatchers("/super", "/super/*").hasAuthority(Authority.SUPER.toString())
// 普通管理员和超级管理员都可以访问的功能
.antMatchers("/manager", "/manager/*").hasAnyAuthority(Authority.SECRETARY.toString(), Authority.ADMIN.toString())
// 普通技术员工就可以访问的功能
// 如果你的Role枚举类在设计时已经携带了级别角色前缀”ROLE_“,直接传参Role.ROLE_TD.toString()即可。
.antMatchers("/member/technolog").hasRole("ROLE_"+Role.TD.toString())
// 只有普通管理员和超级管理员中的技术部成员才能访问的功能
// 注意,Security关于这方面的功能我并没有实证过,Security是否会先检查admin/*请求需要admin权限,然后再检查admin/technolog请求需要ROLE_TD角色,这需要读者自行鉴定。
.antMatchers("/admin/technolog", "/super/technolog", "/manager/technolog").hasRole("ROLE_"+Role.TD.toString())
.anyRequest().authenticated();
// 自定义用户没有登录或者没有权限时的处理方式
http.exceptionHandling()
// 用户未登录
.authenticationEntryPoint(new AuthenticationEntryPoint() {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException e) throws IOException, ServletException {
String xRequestedWith = request.getHeader("X-Requested-With");
// 异步请求
if("XMLHttpRequest".equals(xRequestedWith)){
response.setContentType("application/json;charset=utf-8");
PrintWriter writer = response.getWriter();
writer.write(JSON.toJSONString(R.error(403, e.getMessage())));
}else{
// 同步请求
response.sendRedirect(request.getContextPath() + "/login");
}
}
})
// 用户没有相应页面的操作权限
.accessDeniedHandler(new AccessDeniedHandler() {
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException e) throws IOException, ServletException {
String xRequestedWith = request.getHeader("X-Requested-With");
// 异步请求
if("XMLHttpRequest".equals(xRequestedWith)){
response.setContentType("application/json;charset=utf-8");
PrintWriter writer = response.getWriter();
writer.write(JSON.toJSONString(R.error(403, e.getMessage())));
}else{
// 同步请求
request.setAttribute(ATTRIBUTE_MESSAGE, R.error(e.getMessage()));
request.getRequestDispatcher("/404").forward(request, response);
}
}
});
// 禁用SpringSecurity默认使用X-Frame-Options防止网页被Frame,如果项目中使用到iframe层弹窗需要禁用它。
http.headers().frameOptions().disable();
// 不禁用SpringSecurity CSRF安全认证,开启拦截CSRF网络攻击的功能
//http.csrf().disable();
}
}
@Component
public class MyFailureHandler extends SimpleUrlAuthenticationFailureHandler implements ProjectConstant {
@Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
/*
* SpringSecurity认证失败后默认返回302状态码
* 302状态码会引起前端发起 /login重定向,从而覆盖转发内容
* 经测试,直接通过response.setStatus(403)是无法修改成功的
* SpringSecurity依然会修改状态码为302,细节原因暂时不清
* 经测试,在Controller层添加 /login post方法后,response.setStatus()能设置成功
* 而且此时不通过response.setStatus()修改状态码也能正常转发
* 暂且把这种解决方法称作一个善意的欺骗,避免SpringSecurity产生302状态码
*
* 在解决此问题之前,我采用的是重定向传参的方法,但要解决中文参数乱码的问题
* String msg = URLEncoder.encode(e.getMessage(), "UTF-8");
* response.sendRedirect(request.getContextPath() + "/login?msg="+ msg);
*/
request.setAttribute(ATTRIBUTE_MESSAGE, R.error(exception.getMessage()));
request.getRequestDispatcher("/login").forward(request, response);
}
}
@Component
public class CaptchaFilter extends OncePerRequestFilter implements ProjectConstant {
@Autowired
private MyFailureHandler failureHandler;
@Autowired
private RedisTemplate redisTemplate;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
// 判断是否登录请求,登录请求才检查验证码
if((request.getContextPath()+"/login").equals(request.getRequestURI()) && "POST".equals(request.getMethod())){
/*下列方法是检验验证码是否正确,通过failureHandler将验证码错误信息返回给前端
需要加return,不然filterChain.doFilter又放行,将导致response has been committed错误,
由于全局异常类只能处理Controller层,所以需要手动捕获异常*/
try {
validateCaptcha(request);
}catch (CaptchaException e){
failureHandler.onAuthenticationFailure(request, response, e);
return;
}
filterChain.doFilter(request, response);
}
filterChain.doFilter(request, response);
}
private void validateCaptcha(HttpServletRequest request) {
// 此处自己规定前端验证码参数必须为”captcha“
String captcha = request.getParameter(”captcha“);
if(StringUtils.isBlank(captcha)){
throw new CaptchaException("验证码不能为空!");
}
String captchaKey = RedisKeyUtils.getCaptchaKey(request.getRemoteAddr());
Object captchaRedis = redisTemplate.opsForValue().get(captchaKey);
if(ObjectUtils.isEmpty(captchaRedis)){
throw new CaptchaException("验证码已失效,请刷新!");
}
String captchaLogin = (String) captchaRedis;
if(!captcha.toUpperCase().equals(captchaLogin)){
throw new CaptchaException("验证码错误!");
}
}
}
此处,验证码文本我是存储在Redis中的,用request.getRemoteAddr()获取客户端IP地址作为验证码的拥有者。初学者可以存储在Session中,Session对每个用户都是独立空间,不用额外指定验证码的所有者。
Security默认会生成一个简陋的登录页,只能输入用户名和密码,没有remember-me复选框,更没有图形验证码,所以自定义我们自己的登录页肯定是必须的。这就需要Controller层的SpringMVC方法配合。
// return "login"就是返回我们自定义的登录视图页面了,这个前端页面就不用我给出来了吧!
// 这个MCV方法之所以同时绑定了POST请求,是因为我在MyFailureHandler代码注释中讲到的善意的欺骗,算是Security使用中的一个坑吧,以这种巧妙的方式解决了。
@RequestMapping(value = "/login", method = {RequestMethod.POST, RequestMethod.GET})
public String login(){
return "login";
}
提到Controller层,其实Security也支持在Controller层以注解的方式绑定请求路径和用户权限的关系。常用的注解有@Secured和@PreAuthorize。
// 表示只有技术部成员才能访问的功能
@Secured("ROLE_TD")
@ResponseBody
@GetMapping("/member/technolog")
public String teacher(){
// 业务代码略
return JSON.toJSONString(R.ok());
}
// 表示只有普通管理员中的技术部成员才能访问的功能
// 之前在SecurityConfig中写的hasAuthority与hasRole配合使用的例子我不知道对不对,但用注解@PreAuthorize的这个写法我确定是对的
@PreAuthorize("hasAuthority('ADMIN') && hasRole('ROLE_TD')")
@ResponseBody
@GetMapping("/admin/technolog")
public String manager(){
// 业务代码略
return JSON.toJSONString(R.ok());
}
SpringSecurity开启拦截csrf网络攻击的功能后,会默认在前端所有的form表单增加一个隐藏框,用于识别当前访问环境的安全性。如下:
<input type="hidden" name="_csrf" value="XXXXXXXXXXXXXXXXXXXXXXXX" />
那么,表单请求的确是自动携带了_csrf值,异步请求怎么办呢,异步请求不会自动携带_csrf值,默认会被Security拦截。这时候就需要我们手动携带_csrf值了,以AJAX请求为例:
1、在HTML页面指定meta标签传值_csrf.headerName与_csrf.token。
<meta name="_csrf_header" th:content="${_csrf.headerName}">
<meta name="_csrf" th:content="${_csrf.token}">
2、在JavaScript脚本中指定JAX请求携带CSRF令牌
$(document).ajaxSend(function (e, xhr, options) {
var key = $("meta[name='_csrf_header']").attr("content");
var value = $("meta[name='_csrf']").attr("content");
xhr.setRequestHeader(key, value);
});
由此,AJAX异步请求就可以正常访问了。
之前,我们遗留了一个问题,就是Thymeleaf为支持Security提供的依赖有什么作用?这里既然讲到了Thymeleaf,就简单提一下那个依赖的使用。
1、声明此依赖在前端模板中的对象名,就像声明th="http://www.thymeleaf.org"一样。
<html xmlns:th="http://www.thymeleaf.org" xmlns:sec="http://www.thymeleaf.org/extras/spring-security">
2、使用sec对象
<div sec:authorize ="hasAuthority('ADMIN')" >
div >
<div sec:authorize ="hasRole('ROLE_TD')" >
div >
可以看出,Thymeleaf提供的这个依赖是为了契合Security的配置习惯,用与后端配置类相同的hasAuthority与hasRole语句来绑定前端页面显示与用户权限的关系。
然而,在实际开发中,登录用户的User对象我们肯定会传给前端的,即我们之前缓存在Security的User对象。在所有设计转发视图的SpringMVC方法中,我们都会把User对象绑定到视图中,根据这个User对象依然可以实现判断当前用户权限的目的,进而使用th对象就可以绑定前端页面显示与用户权限的关系,不用引入sec。
1、我们可以在后端创建一个工具类,获取Security缓存的User对象,供业务层代码调用,如下:
@Component
public class SecurityHolder {
@SneakyThrows
public User getUser() {
Object principal = SecurityContextHolder.getContext().getAuthentication().getPrincipal();
if(principal instanceof User){
return (User)principal;
}
throw new PrincipalException("SpringSecurity保存的Authentication对象中主要信息Principal无法转换为User对象或者为空");
}
public String getUsername(){
return SecurityContextHolder.getContext().getAuthentication().getName();
}
}
2、SpringMVC方法绑定视图与数据
@GetMapping("/main")
public String main(Model model){
User user = securityHolder.getUser();
model.addAttribute("user", user);
return "main";
}
3、Thymeleaf识别User对象做到前端权限隔离
<div th:if ="${user.authority.code == 2}" >
div >
<div th:if ="${user.role.code == 4}" >
div >