1. 简介
1.1 认识SpringSecurity
Spring Security 是 Spring 家族中的一个安全管理框架。相比与另外一个安全框架 Shiro,它提供了更丰富的功能,社区资源也比 Shiro 丰富。一般来说中大型的项目都是使用 SpringSecurity 来做安全框架。小项目有 Shiro 的比较多,因为相比与 SpringSecurity,Shiro 的上手更加的简单。
一般Web应用的需要进行认证和授权。
- 认证:验证当前访问系统的是不是本系统的用户,并且要确认具体是哪个用户。
- 授权:经过认证后判断当前用户是否有权限进行某个操作。
1.2 引入依赖
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-security</artifactId></dependency>
1.3 UserDetailsService
当什么也没有配置的时候,账号和密码是由 Spring Security 定义生成的。而在实际项目中账号和密码都是从数据库中查询出来的。所以我们要通过自定义逻辑控制认证逻辑。只需要实现 UserDetailsService 接口即可。接口定义如下: ```java public interface UserDetailsService { // 用来判断用户是否存在,从数据库中获取用户信息,返回一个 UserDetails 对象。 // UserDetails 有一个具体实现类 User,上面方法返回的是一个 User 对象。 UserDetails loadUserByUsername(String username) throws UsernameNotFoundException; }
public interface UserDetails extends Serializable { Collection<? extends GrantedAuthority> getAuthorities(); String getPassword(); String getUsername(); boolean isAccountNonExpired(); … }
public class User implements UserDetails, CredentialsContainer {
private String password;
private final String username;
private final Set
// 用户名是前端传递过来的,密码是数据库中查询出来的,Spring Security会根据User中的password和客户端传递过来的password进行比较。如果相
同则表示认证通过,如果不相同表示认证失败。 public User(String username, String password, Collection<? extends GrantedAuthority> authorities) { this(username, password, true, true, true, true, authorities); } }
<a name="Ygigl"></a>## 1.4 PasswordEncoderPassWordEncoder 本质是一个接口,主要对用户密码进行加密及匹配操作,Spring Security 要求容器中必须有 PasswordEncoder 实例。所以当自定义登录逻辑时要求必须给容器注入 PaswordEncoder 的 bean 对象。我们常用的是 BCryptPasswordEncoder 实现类,主要有两个方法,encode 对密码进行盐加密,match 进行密码匹配。```javapublic class BCryptPasswordEncoder implements PasswordEncoder {public String encode(CharSequence rawPassword) {if (rawPassword == null) {throw new IllegalArgumentException("rawPassword cannot be null");}String salt;if (random != null) {salt = BCrypt.gensalt(version.getVersion(), strength, random);} else {salt = BCrypt.gensalt(version.getVersion(), strength);}return BCrypt.hashpw(rawPassword.toString(), salt);}public boolean matches(CharSequence rawPassword, String encodedPassword) {if (rawPassword == null) {throw new IllegalArgumentException("rawPassword cannot be null");}if (encodedPassword == null || encodedPassword.length() == 0) {logger.warn("Empty encoded password");return false;}if (!BCRYPT_PATTERN.matcher(encodedPassword).matches()) {logger.warn("Encoded password does not look like BCrypt");return false;}return BCrypt.checkpw(rawPassword.toString(), encodedPassword);}}
1.5 常用接口图
2. 资源管理
2.1 关闭CSRF
// 关闭csrfhttp.cors().and().csrf().disable();
2.2 登录、登出、异常处理
// 登入处理http.formLogin().permitAll().successHandler(authenticationSuccessHandler()).failureHandler(authenticationFailureHandler());// 登出处理http.logout().permitAll().logoutSuccessHandler(logoutSuccessHandler()).deleteCookies("JSESSIONID"); // 登出之后删除cookie// 异常处理http.exceptionHandling().accessDeniedHandler(accessDeniedHandler()) //权限拒绝处理逻辑.authenticationEntryPoint(authenticationEntryPoint()); // 匿名用户访问无权限资源时的异常处理
2.3 页面访问权限
2.3.1 访问控制URL匹配
http.authorizeRequests().anyRequest().authenticated():表示匹配所有的请求,一般情况下此方法都会使用,设置全部内容都需要进行认证。http.authorizeRequests().antMatchers("/js/**","/css/**").permitAll():不定向参数匹配,一般用来放行静态资源。http.authorizeRequests().regexMatchers( ".+[.]js").permitAll():使用正则表达式进行匹配,类似于 .antMatchers。http.authorizeRequests().mvcMatchers("/demo").servletPath("/yjxxt").permitAll():适用于配置了 servletPath 的情况,等效于 .antMatchers("/yjxxt/demo").permitAll()
2.3.2 内置访问控制方法
匹配了 URL 后调用了 permitAll() 表示不需要认证,随意访问。在 Spring Security 中提供了多种内置控制。
http.authorizeRequests().anyRequest().permitAll():表示所匹配的 URL 任何人都允许访问。http.authorizeRequests().anyRequest().denyAll():所匹配的 URL 都不允许被访问http.authorizeRequests().anyRequest().authenticated():表示所匹配的 URL 都需要被认证才能访问。http.authorizeRequests().anyRequest().http.authorizeRequests().anyRequest().anonymous(): 表示可以匿名访问匹配的URL。和permitAll()效果类似,只是设置为anonymous()的 url 会执行 filter 链中http.authorizeRequests().anyRequest().rememberMe():被“remember me”的用户允许访问。
2.3.3 角色权限判断
hasAuthority(String):判断用户是否具有特定的权限,用户的权限是在自定义登录逻辑中创建 User 对象时指定的。在配置类中通过 hasAuthority(“admin”) 设置具有 admin 权限时才能访问。hasRole(String):如果用户具备给定角色就允许访问,否则出现 403。参数取值来源于自定义登录逻辑UserDetailsService 实现类中创建 User 对象时给 User 赋予的授权。在给用户赋予角色时角色需要以: ROLE开头 ,后面添加角色名称。例如:ROLE_abc 其中 abc 是角色名,ROLE是固定的字符开头。hasIpAddress(String):如果请求是指定的 IP 就运行访问,注意的是在本机进行测试时 localhost 和 127.0.0.1 输出的 ip地址是不一样的。 ```java .antMatchers(“/main1.html”).hasAuthority(“admin”) //访问main1.html的用户是否具有admin权限 .antMatchers(“/main1.html”).hasAnyAuthority(“admin”, “adMin”) //具有两个权限中的一个
.antMatchers(“/main1.html”).hasRole(“abc”) //判断用户是否具备当前角色 abc .antMatchers(“/main1.html”).hasIpAddress(“127.0.0.1”) //根据ip地址进行访问
<a name="I8URi"></a>### 2.3.4 access方法解读前面所使用的 permitAll() 和 hasRole 底层使用的是 access() 方法。<br /><br />案例:判断登录用户是否具有访问当前 URL 权限。- 接口```java@Servicepublic class MyServiceImpl implements MyService{@Overridepublic boolean hasPermission(HttpServletRequest request, Authentication authentication) {Object obj = authentication.getPrincipal();if (obj instanceof UserDetails){UserDetails userDetails = (UserDetails) obj;Collection<? extends GrantedAuthority> authorities = userDetails.getAuthorities();SimpleGrantedAuthority simpleGrantedAuthority = new SimpleGrantedAuthority(request.getRequestURI());Boolean flag = authorities.contains(simpleGrantedAuthority);System.out.println(flag);return flag;}return false;}}
配置类
// 页面访问权限http.authorizeRequests()// 登录页面执行放行.antMatchers("/login.html").access("permitAll()").antMatchers("/error.html").access("permitAll()")// 其他资源均需登录才可访问//.anyRequest().authenticated();.anyRequest().access("@myServiceImpl.hasPermission(request, authentication)");
2.3.5 基于注解权限控制
通过
@EnableGlobalMethodSecurity(securedEnabled = true)进行开启后使用,控制接口 URL 是否允许被访问。@Secured:判断当前用户是否具有某个角色。@PreAuthorize:表示访问方法或类在执行之前先判断权限,大多情况下都是使用这个注解,注解的参数和 access() 方法参数取值相同,都是权限表达式。@PostAuthorize:表示方法或类执行结束后判断权限,此注解很少被使用到。@PreAuthorize("hasRole('ROLE_abc')")@RequestMapping("toMain")public String main(){return "redirect:main.html";}
3. 基于Cookie整合
3.1 引入依赖
```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 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<artifactId>springsecurity-demo</artifactId><groupId>com.xuwei</groupId><version>1.0-SNAPSHOT</version>
4.0.0 springboot-security 11 11 com.zaxxer HikariCP org.springframework.boot spring-boot-starter-jdbc org.apache.tomcat tomcat-jdbc com.baomidou mybatis-plus-boot-starter com.baomidou mybatis-plus mysql mysql-connector-java ${mysql.version} org.springframework.boot spring-boot-starter-security ${spring-boot.version} com.alibaba fastjson org.apache.commons commons-lang3 3.12.0 org.projectlombok lombok org.springframework.boot spring-boot-starter org.springframework.boot spring-boot-starter-web org.springframework.boot spring-boot-starter-test test org.springframework.boot spring-boot-starter-thymeleaf
<a name="EmthG"></a>## 3.2 引入sql文件[springboot_security.sql](https://www.yuque.com/attachments/yuque/0/2022/sql/446852/1646746353062-f13e6a89-89a5-43dd-b567-d077e3869a18.sql?_lake_card=%7B%22src%22%3A%22https%3A%2F%2Fwww.yuque.com%2Fattachments%2Fyuque%2F0%2F2022%2Fsql%2F446852%2F1646746353062-f13e6a89-89a5-43dd-b567-d077e3869a18.sql%22%2C%22name%22%3A%22springboot_security.sql%22%2C%22size%22%3A7693%2C%22type%22%3A%22%22%2C%22ext%22%3A%22sql%22%2C%22status%22%3A%22done%22%2C%22taskId%22%3A%22u8c3b9e5b-b3bc-4677-94fe-f74087e5753%22%2C%22taskType%22%3A%22transfer%22%2C%22id%22%3A%22fJddj%22%2C%22card%22%3A%22file%22%7D)<a name="L5M0O"></a>## 3.3 配置类```java@Configurationpublic class SecurityConfig extends WebSecurityConfigurerAdapter {/*** 用户认证* @return*/@Beanpublic UserDetailsService userDetailsService() {// 获取用户账号密码及权限信息return new UserDetailsServiceImpl();}/*** 密码加密,多次加密结果是不同的,通过encode加密,matches密码匹配* @return*/@Beanpublic BCryptPasswordEncoder passwordEncoder() {// 设置默认的加密方式(强hash方式加密)return new BCryptPasswordEncoder();}/*** 匿名用户访问无权限资源* @return*/@Beanpublic AuthenticationEntryPoint authenticationEntryPoint() {return new CustomizeAuthenticationEntryPoint();}/*** 权限拒绝处理器,即没有权限访问返回403* @return*/@Beanpublic AccessDeniedHandler accessDeniedHandler() {return new CustomizeAccessDeniedHandler();}@Beanpublic AuthenticationSuccessHandler authenticationSuccessHandler() {return new CustomizeAuthenticationSuccessHandler();}@Beanpublic AuthenticationFailureHandler authenticationFailureHandler() {return new CustomizeAuthenticationFailureHandler();}// 访问决策管理器@Autowiredprivate CustomizeAccessDecisionManager accessDecisionManager;// 设置安全元数据源@Autowiredprivate CustomizeFilterInvocationSecurityMetadataSource securityMetadataSource;// 权限拦截器@Autowiredprivate CustomizeAbstractSecurityInterceptor securityInterceptor;@Beanpublic LogoutSuccessHandler logoutSuccessHandler() {return new CustomizeLogoutSuccessHandler();}@Overrideprotected void configure(HttpSecurity http) throws Exception {// 关闭csrfhttp.cors().and().csrf().disable();// 登入处理http.formLogin().permitAll().successHandler(authenticationSuccessHandler()).failureHandler(authenticationFailureHandler());// 登出处理http.logout().permitAll().logoutSuccessHandler(logoutSuccessHandler()).deleteCookies("JSESSIONID"); // 登出之后删除cookie// 异常处理http.exceptionHandling().accessDeniedHandler(accessDeniedHandler()) //权限拒绝处理逻辑.authenticationEntryPoint(authenticationEntryPoint()); // 匿名用户访问无权限资源时的异常处理// 页面访问权限http.authorizeRequests()//.antMatchers("/getUser").hasAuthority("query_user"); // 权限控制,写死的.withObjectPostProcessor(new ObjectPostProcessor<FilterSecurityInterceptor>() {@Overridepublic <O extends FilterSecurityInterceptor> O postProcess(O o) {o.setAccessDecisionManager(accessDecisionManager);//决策管理器o.setSecurityMetadataSource(securityMetadataSource);//安全元数据源return o;}});http.authorizeRequests().anyRequest().authenticated();http.addFilterBefore(securityInterceptor, FilterSecurityInterceptor.class);}@Overrideprotected void configure(AuthenticationManagerBuilder auth) throws Exception {// 配置认证方式等auth.userDetailsService(userDetailsService());}}
3.4 用户认证逻辑
public class UserDetailsServiceImpl implements UserDetailsService {@Autowiredprivate SysUserService sysUserService;@Autowiredprivate SysPermissionService sysPermissionService;@Overridepublic UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {//需要构造出 org.springframework.security.core.userdetails.User 对象并返回if (username == null || "".equals(username)) {throw new RuntimeException("用户名不能为空");}// 根据用户名查找用户SysUser sysUser = sysUserService.selectByName(username);if (sysUser == null) {throw new RuntimeException("用户不存在");}List<GrantedAuthority> grantedAuthorities = new ArrayList<>();if (sysUser != null) {//获取该用户所拥有的权限List<SysPermission> sysPermissions = sysPermissionService.selectListByUser(sysUser.getId());// 声明用户授权sysPermissions.forEach(sysPermission -> {GrantedAuthority grantedAuthority = new SimpleGrantedAuthority(sysPermission.getPermissionCode());grantedAuthorities.add(grantedAuthority);});}return new User(sysUser.getAccount(),sysUser.getPassword(),sysUser.getEnabled(),sysUser.getAccountNonExpired(),sysUser.getCredentialsNonExpired(),sysUser.getAccountNonLocked(),grantedAuthorities);}}
3.5 相关处理器(登录成功、失败、登出)
登录成功处理器
public class CustomizeAuthenticationSuccessHandler implements AuthenticationSuccessHandler {@Autowiredprivate SysUserService sysUserService;@Overridepublic void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {// 更新用户表上次登录时间、更新人、更新时间等字段User userDetails = (User) SecurityContextHolder.getContext().getAuthentication().getPrincipal();SysUser sysUser = sysUserService.selectByName(userDetails.getUsername());sysUser.setLastLoginTime(new Date());sysUser.setUpdateTime(new Date());sysUser.setUpdateUser(sysUser.getId());sysUserService.updateById(sysUser);//此处还可以进行一些处理,比如登录成功之后可能需要返回给前台当前用户有哪些菜单权限,//进而前台动态的控制菜单的显示等,具体根据自己的业务需求进行扩展// 返回json数据JsonResult result = ResultTool.success();//处理编码方式,防止中文乱码的情况response.setContentType("text/json;charset=utf-8");//塞到HttpServletResponse中返回给前台response.getWriter().write(JSON.toJSONString(result));}}
登录失败处理器
public class CustomizeAuthenticationFailureHandler implements AuthenticationFailureHandler {@Overridepublic void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException e) throws IOException, ServletException {//返回json数据JsonResult result = null;if (e instanceof AccountExpiredException) {//账号过期result = ResultTool.fail(ResultCode.USER_ACCOUNT_EXPIRED);} else if (e instanceof BadCredentialsException) {//密码错误result = ResultTool.fail(ResultCode.USER_CREDENTIALS_ERROR);} else if (e instanceof CredentialsExpiredException) {//密码过期result = ResultTool.fail(ResultCode.USER_CREDENTIALS_EXPIRED);} else if (e instanceof DisabledException) {//账号不可用result = ResultTool.fail(ResultCode.USER_ACCOUNT_DISABLE);} else if (e instanceof LockedException) {//账号锁定result = ResultTool.fail(ResultCode.USER_ACCOUNT_LOCKED);} else if (e instanceof InternalAuthenticationServiceException) {//用户不存在result = ResultTool.fail(ResultCode.USER_ACCOUNT_NOT_EXIST);}else{//其他错误result = ResultTool.fail(ResultCode.COMMON_FAIL);}//处理编码方式,防止中文乱码的情况response.setContentType("text/json;charset=utf-8");//塞到HttpServletResponse中返回给前台response.getWriter().write(JSON.toJSONString(result));}}
登出成功处理器
public class CustomizeLogoutSuccessHandler implements LogoutSuccessHandler {@Overridepublic void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {JsonResult result = ResultTool.success();response.setContentType("text/json;charset=utf-8");response.getWriter().write(JSON.toJSONString(result));}}
匿名用户无权访问资源处理器
public class CustomizeAuthenticationEntryPoint implements AuthenticationEntryPoint {@Overridepublic void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {JsonResult result = ResultTool.fail(ResultCode.USER_NOT_LOGIN);response.setContentType("text/json;charset=utf-8");response.getWriter().write(JSON.toJSONString(result));}}
权限拒绝处理逻辑
public class CustomizeAccessDeniedHandler implements AccessDeniedHandler {@Overridepublic void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {response.setStatus(HttpServletResponse.SC_FORBIDDEN);JsonResult result = ResultTool.fail(ResultCode.NO_PERMISSION);response.setContentType("text/json;charset=utf-8");response.getWriter().write(JSON.toJSONString(result));}}
3.6 权限校验
我们已经实现了一个所谓的基于 RBAC 的权限控制,只不过我们是在 WebSecurityConfig 中写死的,但是在平时开发中,不可能说加一个需要访问权限的资源都修改代码。最合理的办法是从数据库中获取请求 URL 的权限,当前用户是否已授权访问。
- 我们需要实现一个
AccessDecisionManager(访问决策管理器),在里面我们对当前请求的资源进行权限判断,判断当前登录用户是否拥有该权限,如果有就放行,如果没有就抛出一个”权限不足”的异常。 - 不过在实现 AccessDecisionManager 之前我们还需要做一件事,那就是拦截到当前的请求,并根据请求路径从数据库中查出当前资源路径需要哪些权限才能访问,然后将查出的需要的权限列表交给 AccessDecisionManager 去处理后续逻辑。那就是需要先实现一个 SecurityMetadataSource,翻译过来是”安全元数据源”,我们这里使用他的一个子类 FilterInvocationSecurityMetadataSource。
- 在自定义的 SecurityMetadataSource 编写好之后,我们还要编写一个拦截器,增加到 Spring security 默认的拦截器链中,以达到拦截的目的。
- 同样的最后需要在 WebSecurityConfig 中注入,并在 configure(HttpSecurity http) 方法中然后声明。
访问决策管理器
@Componentpublic class CustomizeAccessDecisionManagerimplements AccessDecisionManager {@Overridepublic void decide(Authentication authentication, Object object, Collection<ConfigAttribute> configAttributes) throws AccessDeniedException, InsufficientAuthenticationException {Iterator<ConfigAttribute> iterator = configAttributes.iterator();while (iterator.hasNext()) {ConfigAttribute ca = iterator.next();//当前请求需要的权限String needRole = ca.getAttribute();//当前用户所具有的权限,UserDetailsService中传递过来的Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();for (GrantedAuthority authority : authorities) {if (authority.getAuthority().equals(needRole)) {return;}}}throw new AccessDeniedException("权限不足!");}@Overridepublic boolean supports(ConfigAttribute attribute) {return true;}@Overridepublic boolean supports(Class<?> clazz) {return true;}}
安全元数据源
@Componentpublic class CustomizeFilterInvocationSecurityMetadataSourceimplements FilterInvocationSecurityMetadataSource {AntPathMatcher antPathMatcher = new AntPathMatcher();@AutowiredSysPermissionService sysPermissionService;/*** 获取请求路径对应的权限* @param object* @return* @throws IllegalArgumentException*/@Overridepublic Collection<ConfigAttribute> getAttributes(Object object) throws IllegalArgumentException {//获取请求地址String requestUrl = ((FilterInvocation) object).getRequestUrl();//查询具体某个接口的权限List<SysPermission> permissionList = sysPermissionService.selectListByPath(requestUrl);if(permissionList == null || permissionList.size() == 0){//请求路径没有配置权限,表明该请求接口可以任意访问return null;}String[] attributes = new String[permissionList.size()];for(int i = 0;i<permissionList.size();i++){attributes[i] = permissionList.get(i).getPermissionCode();}return SecurityConfig.createList(attributes);}@Overridepublic Collection<ConfigAttribute> getAllConfigAttributes() {return null;}@Overridepublic boolean supports(Class<?> clazz) {return true;}}
拦截器
@Componentpublic class CustomizeAbstractSecurityInterceptorextends AbstractSecurityInterceptor implements Filter {/*** 过滤调用安全元数据源*/@Autowiredprivate FilterInvocationSecurityMetadataSource securityMetadataSource;/*** 设置访问决策管理器* @param accessDecisionManager*/@Autowiredpublic void setMyAccessDecisionManager(CustomizeAccessDecisionManager accessDecisionManager) {super.setAccessDecisionManager(accessDecisionManager);}@Overridepublic Class<?> getSecureObjectClass() {return FilterInvocation.class;}@Overridepublic SecurityMetadataSource obtainSecurityMetadataSource() {return this.securityMetadataSource;}@Overridepublic void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {FilterInvocation fi = new FilterInvocation(servletRequest, servletResponse, filterChain);invoke(fi);}public void invoke(FilterInvocation fi) throws IOException, ServletException {//fi里面有一个被拦截的url//里面调用MyInvocationSecurityMetadataSource的getAttributes(Object object)这个方法获取fi对应的所有权限//再调用MyAccessDecisionManager的decide方法来校验用户的权限是否足够InterceptorStatusToken token = super.beforeInvocation(fi);try {//执行下一个拦截器fi.getChain().doFilter(fi.getRequest(), fi.getResponse());} finally {super.afterInvocation(token, null);}}}
