一、Spring Security

Spring Security作为Spring家族的安全框架,在安全方面的两个核心功能是认证(Authentication)和授权(Authorization)。

(1)用户认证指的是:验证某个用户是否为系统中的合法主体,也就是说用户能否访问该系统。用户认证一般要求用户提供用户名和密码,系统通过校验用户名和密码来完成认证过程。
通俗点说就是系统认为用户是否能登录
(2)用户授权指的是:验证某个用户是否有权限执行某个操作。在一个系统中,不同用户所具有的权限是不同的。比如对一个文件来说,有的用户只能进行读取,而有的用户可以进行修改。一般来说,系统会为不同的用户分配不同的角色,而每个角色则对应一系列的权限。
通俗点讲就是系统判断用户是否有权限去做某些事情。

前后端交互

二、Spring Security实现权限

Spring Security的原理是一个过滤器链,Security提供了各种功能的过滤器。
要对Web资源进行保护,最好的办法莫过于Filter
要想对方法调用进行保护,最好的办法莫过于[AOP]

如图所示,一个请求想要访问到API就会从左到右经过蓝线框里的过滤器,其中绿色部分是负责认证的过滤器,蓝色部分是负责异常处理,橙色部分则是负责授权。进过一系列拦截最终访问到我们的API。
这里面有两个重要的过滤器:UsernamePasswordAuthenticationFilter负责登录认证,FilterSecurityInterceptor负责权限授权。
ExceptionTranslationFilter负责过滤器链中抛出的任何AccessDeniedExceptionAuthenticationException。
说明:Spring Security的核心逻辑全在这一套过滤器中,过滤器里会调用各种组件完成功能,掌握了这些过滤器和组件你就掌握了Spring Security!这个框架的使用方式就是对这些过滤器和组件进行扩展。

1、SpringSecurity编码入门
1.1 添加依赖

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

依赖导入后会引入Security默认的安全功能,包括要求经过身份验证的用户才能与应用程序进行交互,创建好了默认登录表单,生成用户名为user的随机密码并打印在控制台上,CSRF攻击防护、Session Fixation攻击防护等。

1.2、启动项目测试
在浏览器访问:http://localhost:8800就会弹出security默认的登录,用户名是user,结合控制台给出的密码就可以完成登录访问API。正常执行上述操作就说明Spring Security默认安全保护生效。

2、用户认证

概念速查:
Authentication接口: 它的实现类,表示当前访问系统的用户,封装了用户相关信息。
AuthenticationManager接口:定义了认证Authentication的方法
UserDetailsService接口:加载用户特定数据的核心接口。里面定义了一个根据用户名查询用户信息的方法。
UserDetails接口:提供核心用户信息。通过UserDetailsService根据用户名获取处理的用户信息要封装成UserDetails对象返回。然后将这些信息封装到Authentication对象中。

2.1、用户认证核心组件
我们系统中会有许多用户,确认当前是哪个用户正在使用我们系统就是登录认证的最终目的。这里我们就提取出了一个核心概念:当前登录用户/当前认证用户。整个系统安全都是围绕当前登录用户展开的,这个不难理解,要是当前登录用户都不能确认了,那A下了一个订单,下到了B的账户上这不就乱套了。这一概念在Spring Security中的体现就是 Authentication,它存储了认证信息,代表当前登录用户。

我们在程序中如何获取并使用它呢?我们需要通过 SecurityContext 来获取AuthenticationSecurityContext就是我们的上下文对象!这个上下文对象则是交由 SecurityContextHolder 进行管理,你可以在程序任何地方使用它:

Authentication authentication = SecurityContextHolder.getContext().getAuthentication();

SecurityContextHolder原理就是使用ThreadLocal来保证一个线程中传递同一个对象!
现在我们已经知道了Spring Security中三个核心组件:

​ 1、Authentication:存储了认证信息,代表当前登录用户

​ 2、SeucirtyContext:上下文对象,用来获取Authentication

​ 3、SecurityContextHolder:上下文管理对象,用来在程序任何地方获取SecurityContext

Authentication中是什么信息呢:

​ 1、Principal:用户信息,没有认证时一般是用户名,认证后一般是用户对象

​ 2、Credentials:用户凭证,一般是密码

​ 3、Authorities:用户权限

2.2、用户认证
Spring Security是怎么进行用户认证的呢?

AuthenticationManager 就是Spring Security用于执行身份验证的组件,只需要调用它的authenticate()方法即可完成认证。Spring Security默认的认证方式就是在UsernamePasswordAuthenticationFilter这个过滤器中进行认证的,该过滤器负责认证逻辑。

Spring Security用户认证关键代码如下:

// 生成一个包含账号密码的认证信息Authentication authenticationToken = new UsernamePasswordAuthenticationToken(username, passwrod);// AuthenticationManager校验这个认证信息,返回一个已认证的AuthenticationAuthentication authentication = authenticationManager.authenticate(authenticationToken);// 将返回的Authentication存到上下文中SecurityContextHolder.getContext().setAuthentication(authentication);

2.2.1、认证接口分析
AuthenticationManager的校验逻辑:

根据用户名先查询出用户对象(没有查到则抛出异常)将用户对象的密码和传递过来的密码进行校验,密码不匹配则抛出异常。重点是这里每一个步骤Spring Security都提供了组件:

​1、是谁执行 根据用户名查询出用户对象 逻辑的呢?用户对象数据可以存在内存中、文件中、数据库中,你得确定好怎么查才行。这一部分就是交由UserDetialsService 处理,该接口只有一个方法loadUserByUsername(String username),通过用户名查询用户对象,默认实现是在内存中查询。
​ 2、那查询出来的 用户对象 又是什么呢?每个系统中的用户对象数据都不尽相同,咱们需要确认我们的用户数据是啥样的才行。Spring Security中的用户数据则是由UserDetails 来体现,该接口中提供了账号、密码等通用属性。
​ 3、对密码进行校验大家可能会觉得比较简单,if、else搞定,就没必要用什么组件了吧?但框架毕竟是框架考虑的比较周全,除了if、else外还解决了密码加密的问题,这个组件就是PasswordEncoder,负责密码加密与校验。

我们可以看下AuthenticationManager校验逻辑的大概源码:

public Authentication authenticate(Authentication authentication) throws AuthenticationException {// 传递过来的用户名String username = authentication.getName();// 调用UserDetailService的方法,通过用户名查询出用户对象UserDetail(查询不出来UserDetailService则会抛出异常)UserDetails userDetails = this.getUserDetailsService().loadUserByUsername(username);String presentedPassword = authentication.getCredentials().toString();// 传递过来的密码String password = authentication.getCredentials().toString();// 使用密码解析器PasswordEncoder传递过来的密码是否和真实的用户密码匹配if (!passwordEncoder.matches(password, userDetails.getPassword())) {// 密码错误则抛出异常throw new BadCredentialsException("错误信息...");}// 注意,这里返回的已认证Authentication,是将整个UserDetails放进去充当PrincipalUsernamePasswordAuthenticationToken result = new UsernamePasswordAuthenticationToken(userDetails,authentication.getCredentials(), userDetails.getAuthorities());return result;}

UserDetialsServiceUserDetailsPasswordEncoder,这三个组件Spring Security都有默认实现,这一般是满足不了我们的实际需求的,所以这里我们自己来实现这些组件。

2.2.2、加密器PasswordEncoder
采取MD5加密
自定义加密处理组件:CustomMd5PasswordEncoder

import org.springframework.security.crypto.password.PasswordEncoder;import org.springframework.stereotype.Component;import org.springframework.util.DigestUtils;import java.util.Arrays;/** * 自定义security密码校验 */public class CustomMd5PasswordEncoder implements PasswordEncoder {@Overridepublic String encode(CharSequence rawPassword) {// 进行一个md5加密return Arrays.toString(DigestUtils.md5Digest(rawPassword.toString().getBytes()));}@Overridepublic boolean matches(CharSequence rawPassword, String encodedPassword) {// 通过md5校验return encodedPassword.equals(Arrays.toString(DigestUtils.md5Digest(rawPassword.toString().getBytes())));}}

2.2.3、用户对象UserDetails
该接口就是我们所说的用户对象,它提供了用户的一些通用属性,源码如下:

public interface UserDetails extends Serializable {/** * 用户权限集合(这个权限对象现在不管它,到权限时我会讲解) */Collection<" />extends GrantedAuthority> getAuthorities();/** * 用户密码 */String getPassword();/** * 用户名 */String getUsername();/** * 用户没过期返回true,反之则false */boolean isAccountNonExpired();/** * 用户没锁定返回true,反之则false */boolean isAccountNonLocked();/** * 用户凭据(通常为密码)没过期返回true,反之则false */boolean isCredentialsNonExpired();/** * 用户是启用状态返回true,反之则false */boolean isEnabled();}

实际开发中我们的用户属性各种各样,这些默认属性可能是满足不了,所以我们一般会自己实现该接口,然后设置好我们实际的用户实体对象。实现此接口要重写很多方法比较麻烦,我们可以继承Spring Security提供的org.springframework.security.core.userdetails.User类,该类实现了UserDetails接口帮我们省去了重写方法的工作:

import com.sky.model.system.SysUser;import org.springframework.security.core.GrantedAuthority;import org.springframework.security.core.userdetails.User;import java.util.Collection;/** * 自定义user对象 */public class CustomUser extends User {private SysUser sysUser;public CustomUser(SysUser sysUser, Collection<? extends GrantedAuthority> authorities) {super(sysUser.getUsername(), sysUser.getPassword(), authorities);this.sysUser = sysUser;}public SysUser getSysUser() {return sysUser;}public void setSysUser(SysUser sysUser) {this.sysUser = sysUser;}}

2.2.4、 业务对象UserDetailsService
该接口很简单只有一个方法:

public interface UserDetailsService {/** * 根据用户名获取用户对象(获取不到直接抛异常) */UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;}

实现该接口,就完成了自己的业务

import com.sky.model.system.SysUser;import com.sky.system.custom.CustomUser;import com.sky.system.service.SysUserService;import org.springframework.security.core.userdetails.UserDetails;import org.springframework.security.core.userdetails.UserDetailsService;import org.springframework.security.core.userdetails.UsernameNotFoundException;import org.springframework.stereotype.Service;import javax.annotation.Resource;import java.util.Collections;import java.util.Objects;/** * 实现UserDetailsService接口,重写方法 */@Servicepublic class UserDetailsServiceImpl implements UserDetailsService {@Resourceprivate SysUserService sysUserService;@Overridepublic UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {SysUser sysUser = sysUserService.queryByUsername(username);if (Objects.isNull(sysUser)){throw new UsernameNotFoundException("用户名不存在!");}if(sysUser.getStatus() == 0) {throw new RuntimeException("账号已停用");}return new CustomUser(sysUser, Collections.emptyList());}}

2.2.5、登录接口

接下我们需要自定义登陆接口,然后让SpringSecurity对这个接口放行,让用户访问这个接口的时候不用登录也能访问。
​ 在接口中我们通过AuthenticationManagerauthenticate()方法来进行用户认证,所以需要在SecurityConfig中配置把AuthenticationManager注入容器。
认证成功的话要生成一个jwt,放入响应中返回。

@Slf4j@Api(tags = "系统管理-登录管理")@RequestMapping("/admin/system/index")@RestControllerpublic class IndexController {@Resourceprivate SysUserService sysUserService;@ApiOperation("登录接口")@PostMapping("/login")public Result<Map<String,Object>> login(@RequestBody LoginVo loginVo){return sysUserService.login(loginVo);}}

2.2.6、 SecurityConfig配置

package com.sky.system.config;import com.sky.system.custom.CustomMd5PasswordEncoder;import org.springframework.context.annotation.Bean;import org.springframework.context.annotation.Configuration;import org.springframework.http.HttpMethod;import org.springframework.security.authentication.AuthenticationManager;import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;import org.springframework.security.config.annotation.web.builders.HttpSecurity;import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;import org.springframework.security.config.http.SessionCreationPolicy;import org.springframework.security.crypto.password.PasswordEncoder;import org.springframework.security.web.SecurityFilterChain;import org.springframework.web.cors.CorsConfiguration;import org.springframework.web.cors.CorsConfigurationSource;import org.springframework.web.cors.UrlBasedCorsConfigurationSource;import java.util.Collections;/** * Security配置类 */@Configuration/** * @EnableWebSecurity是开启SpringSecurity的默认行为 */@EnableWebSecuritypublic class SecurityConfig {/** * 密码明文加密方式配置 * @return */@Beanpublic PasswordEncoder passwordEncoder(){return new CustomMd5PasswordEncoder();}/** * 获取AuthenticationManager(认证管理器),登录时认证使用 * @param authenticationConfiguration * @return * @throws Exception */@Beanpublic AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception {return authenticationConfiguration.getAuthenticationManager();}@Beanpublic SecurityFilterChain filterChain(HttpSecurity http) throws Exception {returnhttp// 基于 token,不需要 csrf.csrf().disable()// 开启跨域以便前端调用接口.cors().and().authorizeRequests()// 指定某些接口不需要通过验证即可访问。登录接口肯定是不需要认证的.antMatchers("/admin/system/index/login").permitAll()// 静态资源,可匿名访问.antMatchers(HttpMethod.GET, "/", "/*.html", "/**/*.html", "/**/*.css", "/**/*.js", "/profile/**").permitAll().antMatchers("/swagger-ui.html", "/swagger-resources/**", "/webjars/**", "/*/api-docs", "/druid/**","/doc.html").permitAll()// 这里意思是其它所有接口需要认证才能访问.anyRequest().authenticated().and()// 基于 token,不需要 session.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).and()// cors security 解决方案.cors().configurationSource(corsConfigurationSource()).and().build();}/** * 配置跨源访问(CORS) * @return */@Beanpublic CorsConfigurationSource corsConfigurationSource() {CorsConfiguration configuration = new CorsConfiguration();configuration.setAllowedHeaders(Collections.singletonList("*"));configuration.setAllowedMethods(Collections.singletonList("*"));configuration.setAllowedOrigins(Collections.singletonList("*"));configuration.setMaxAge(3600L);UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();source.registerCorsConfiguration("/**", configuration);return source;}}

controller通过login方法调用实际业务

@Servicepublic class SysUserServiceImpl extends ServiceImpl<SysUserMapper, SysUser> implements SysUserService {@Resourceprivate SysMenuService sysMenuService;/** * 通过AuthenticationManager的authenticate方法来进行用户认证, */@Resourceprivate AuthenticationManager authenticationManager;@Overridepublic Result<Map<String, Object>> login(LoginVo loginVo) {// 将表单数据封装到 UsernamePasswordAuthenticationTokenUsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken = new UsernamePasswordAuthenticationToken(loginVo.getUsername(), loginVo.getPassword());// authenticate方法会调用loadUserByUsernameAuthentication authenticate = authenticationManager.authenticate(usernamePasswordAuthenticationToken);if(Objects.isNull(authenticate)){throw new RuntimeException("用户名或密码错误");}// 校验成功,强转对象CustomUser customUser = (CustomUser) authenticate.getPrincipal();SysUser sysUser = customUser.getSysUser();// 校验通过返回tokenString token = JwtUtil.createToken(sysUser.getId(), sysUser.getUsername());Map<String, Object> map = new HashMap<>();map.put("token",token);return Result.ok(map);}}

2.2.7、认证过滤器
我们需要自定义一个过滤器,这个过滤器会去获取请求头中的token,对token进行解析取出其中的信息,获取对应的LoginUser对象。然后封装Authentication对象存入SecurityContextHolder

@Componentpublic class JwtAuthenticationTokenFilter extends OncePerRequestFilter {@Autowiredprivate RedisCache redisCache;@Overrideprotected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {//获取tokenString token = request.getHeader("token");if (!StringUtils.hasText(token)) {//放行filterChain.doFilter(request, response);return;}//解析tokenString userid;try {Claims claims = JwtUtil.parseJWT(token);userid = claims.getSubject();} catch (Exception e) {e.printStackTrace();throw new RuntimeException("token非法");}//从redis中获取用户信息String redisKey = "login:" + userid;LoginUser loginUser = redisCache.getCacheObject(redisKey);if(Objects.isNull(loginUser)){throw new RuntimeException("用户未登录");}//存入SecurityContextHolder//TODO 获取权限信息封装到Authentication中UsernamePasswordAuthenticationToken authenticationToken =new UsernamePasswordAuthenticationToken(loginUser,null,null);SecurityContextHolder.getContext().setAuthentication(authenticationToken);//放行filterChain.doFilter(request, response);}}

3、用户授权
在SpringSecurity中,会使用默认的FilterSecurityInterceptor来进行权限校验。在FilterSecurityInterceptor中会从SecurityContextHolder获取其中的Authentication,然后获取其中的权限信息。判断当前用户是否拥有访问当前资源所需的权限。
SpringSecurity中的Authentication类:

public interface Authentication extends Principal, Serializable {//权限数据列表Collection<? extends GrantedAuthority> getAuthorities();Object getCredentials();Object getDetails();Object getPrincipal();boolean isAuthenticated();void setAuthenticated(boolean var1) throws IllegalArgumentException;}

前面登录时执行loadUserByUsername()方法时,return new CustomUser(sysUser, Collections.emptyList());后面的空数据对接就是返回给Spring Security的权限数据。

在TokenAuthenticationFilter中怎么获取权限数据呢?登录时我们把权限数据保存到redis中(用户名为key,权限数据为value即可),这样通过token获取用户名即可拿到权限数据,这样就可构成出完整的Authentication对象。

3.1、修改loadUserByUsername()接口方法

@Autowiredprivate SysMenuService sysMenuService;
@Overridepublic UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {SysUser sysUser = sysUserService.getByUsername(username);if(null == sysUser) {throw new UsernameNotFoundException("用户名不存在!");}if(sysUser.getStatus().intValue() == 0) {throw new RuntimeException("账号已停用");}List<String> userPermsList = sysMenuService.findUserPermsList(sysUser.getId());List<SimpleGrantedAuthority> authorities = new ArrayList<>();for (String perm : userPermsList) {authorities.add(new SimpleGrantedAuthority(perm.trim()));}return new CustomUser(sysUser, authorities);}

3.2、修改配置类
修改WebSecurityConfig
配置类添加注解:
开启基于方法的安全认证机制,也就是说在web层的controller启用注解机制的安全确认

@EnableGlobalMethodSecurity(prePostEnabled = true)

3.3、控制controller层接口权限
Spring Security默认是禁用注解的,要想开启注解,需要在继承WebSecurityConfigurerAdapter的类上加@EnableGlobalMethodSecurity注解,来判断用户对某个控制层的方法是否具有访问权限

通过@PreAuthorize标签控制controller层接口权限

public class SysRoleController {@Autowiredprivate SysRoleService sysRoleService;@PreAuthorize("hasAuthority('bnt.sysRole.list')")@ApiOperation(value = "获取分页列表")@GetMapping("/{page}/{limit}")public Result index(@ApiParam(name = "page", value = "当前页码", required = true)@PathVariable Long page,@ApiParam(name = "limit", value = "每页记录数", required = true)@PathVariable Long limit,@ApiParam(name = "roleQueryVo", value = "查询对象", required = false)SysRoleQueryVo roleQueryVo) {Page<SysRole> pageParam = new Page<>(page, limit);IPage<SysRole> pageModel = sysRoleService.selectPage(pageParam, roleQueryVo);return Result.ok(pageModel);}}

3.4、测试服务器端权限
登录后台,分配权限进行测试,页面如果添加了按钮权限控制,可临时去除方便测试

测试结论:
​ 1、分配了权限的能够成功返回接口数据
​ 2、没有分配权限的会抛出异常:org.springframework.security.access.AccessDeniedException: 不允许访问

4、异常处理
我们还希望在认证失败或者是授权失败的情况下也能和我们的接口一样返回相同结构的json,这样可以让前端能对响应进行统一的处理。要实现这个功能我们需要知道SpringSecurity的异常处理机制。

​ 在SpringSecurity中,如果我们在认证或者授权的过程中出现了异常会被ExceptionTranslationFilter捕获到。在ExceptionTranslationFilter中会去判断是认证失败还是授权失败出现的异常。

​ 如果是认证过程中出现的异常会被封装成AuthenticationException然后调用AuthenticationEntryPoint对象的方法去进行异常处理。

​ 如果是授权过程中出现的异常会被封装成AccessDeniedException然后调用AccessDeniedHandler对象的方法去进行异常处理。

​ 所以如果我们需要自定义异常处理,我们只需要自定义AuthenticationEntryPointAccessDeniedHandler然后配置给SpringSecurity即可。

异常处理有2种方式:

​ 1、扩展Spring Security异常处理类:AccessDeniedHandlerAuthenticationEntryPoint

​ 2、在spring boot全局异常统一处理

第一种方案说明:如果系统实现了全局异常处理,那么全局异常首先会获取AccessDeniedException异常,要想Spring Security扩展异常生效,必须在全局异常再次抛出该异常。

①自定义实现类

import com.alibaba.fastjson2.JSON;import com.fasterxml.jackson.databind.ObjectMapper;import com.sky.common.result.ResultCodeEnum;import com.sky.common.util.WebUtils;import org.springframework.security.core.AuthenticationException;import org.springframework.security.web.AuthenticationEntryPoint;import org.springframework.stereotype.Component;import javax.servlet.ServletException;import javax.servlet.http.HttpServletRequest;import javax.servlet.http.HttpServletResponse;import java.io.IOException;import java.util.HashMap;import java.util.Map;/** * 认证失败处理 */@Componentpublic class AuthenticationEntryPointImpl implements AuthenticationEntryPoint {@Overridepublic void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {response.setStatus(200);int code = ResultCodeEnum.LOGIN_AUTH.getCode();String msg = "认证失败,无法访问系统资源";response.setContentType("application/json;charset=UTF-8");Map<String, Object> result = new HashMap<>();result.put("msg", msg);result.put("code", code);String s = new ObjectMapper().writeValueAsString(result);response.getWriter().println(s);}}
import com.fasterxml.jackson.databind.ObjectMapper;import com.sky.common.result.ResultCodeEnum;import org.springframework.security.access.AccessDeniedException;import org.springframework.security.web.access.AccessDeniedHandler;import org.springframework.stereotype.Component;import javax.servlet.ServletException;import javax.servlet.http.HttpServletRequest;import javax.servlet.http.HttpServletResponse;import java.io.IOException;import java.util.HashMap;import java.util.Map;@Componentpublic class AccessDeniedHandlerImpl implements AccessDeniedHandler {@Overridepublic void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {int code = ResultCodeEnum.PERMISSION.getCode();response.setStatus(200);response.setContentType("application/json;charset=UTF-8");String msg = "权限不足,无法访问系统资源";Map<String, Object> result = new HashMap<>();result.put("msg", msg);result.put("code", code);String s = new ObjectMapper().writeValueAsString(result);response.getWriter().println(s);}}

②配置给SpringSecurity

​ 先注入对应的处理器

@Autowiredprivate AuthenticationEntryPoint authenticationEntryPoint;@Autowiredprivate AccessDeniedHandler accessDeniedHandler;

然后我们可以使用HttpSecurity对象的方法去配置。

 http.exceptionHandling().authenticationEntryPoint(authenticationEntryPoint).accessDeniedHandler(accessDeniedHandler);