目录
1、什么是 Spring Security 安全框架?
2、关于 SpringSecurity 中的认证
3、关于 SpringSecurity 中的授权
3.1 从数据库中查询用户的权限信息
4、关于自定义失败处理
5、跨域问题
前提引入:
随着科技的完善,现在几乎所有的网站以及软件都需要进行授权认证,使之更加的安全可靠
认证:验证当前访问系统的是不是本系统的用户,并且要确认具体是哪个用户
授权:经过认证后判断当前用户是否有权限进行某个操作
1、什么是 Spring Security 安全框架?
定义:Spring Security 是一个功能强大且灵活的身份验证和授权框架,用于保护基于 Spring 的应用程序;它提供了一套综合的安全性解决方案,可以用于 Web 应用程序、REST API、微服务等各种应用场景
Spring Security 的主要功能和作用如下:
-
身份验证(Authentication):Spring Security 提供了多种身份验证方式,包括基于表单、HTTP 基本认证、LDAP、OAuth2 等。它可以集成到应用程序中,通过验证用户提供的凭据(如用户名和密码)来验证用户身份
-
授权(Authorization):Spring Security 支持基于角色和权限的授权机制。它允许您定义细粒度的访问控制规则,例如指定哪些用户具有访问某些受保护资源的权限
-
攻击防护(Attack Protection):Spring Security 提供了一系列的防护机制来应对常见的安全攻击,例如跨站点请求伪造(CSRF)攻击、会话固定攻击、点击劫持等。它通过配置合适的安全措施来保护应用程序免受这些攻击的风险
-
集成第三方认证系统:Spring Security 可以与其他身份认证系统(如LDAP、OAuth2)进行集成,以便使用这些系统中已有的用户凭据进行身份验证
-
定制化和扩展性:Spring Security 提供了丰富的配置选项和可插拔的拦截器机制,使开发人员可以根据应用程序的需求进行灵活的定制和扩展
-
审计和日志记录:Spring Security 可以记录关于身份验证和授权过程的审计日志,为应用程序的安全性监控和追踪提供支持
2、关于 SpringSecurity 中的认证
登录校验流程图解:
SpringSecurity 安全框架其实是多个过滤器(过滤器链)所组成的,内部包含了各种功能
SpringSecurity 中过滤器详解图:
UsernamePasswordAuthenticationFilter:负责处理我们在登陆页面填写了用户名密码后的登陆请求。入门案例的认证工作主要由它负责
ExceptionTranslationFilter:处理过滤器链中抛出的任何 AccessDeniedException 和AuthenticationException 异常(即 用户授权异常 和 用户认证异常)
FilterSecurityInterceptor:负责权限校验的过滤器
认证流程详解图:
Authentication接口: 它的实现类,表示当前访问系统的用户,封装了用户相关信息
AuthenticationManager接口:定义了认证Authentication的方法
UserDetailsService接口:加载用户特定数据的核心接口;里面定义了一个根据用户名查询用户信息的方法
UserDetails接口:提供核心用户信息;通过 UserDetailsService 根据用户名获取处理的用户信息要封装成 UserDetails 对象返回;然后将这些信息封装到 Authentication 对象中
这里设定一个 UserLogin 类,来继承 UserDetails 接口,用来表示登录用户的信息,便于之后的调用(User 为用户类)
@Data
@NoArgsConstructor
@AllArgsConstructor
public class LoginUser implements UserDetails {
private User user;
/**
* 获取权限信息
*/
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return null;
}
@Override
public String getPassword() {
return user.getPassword();
}
@Override
public String getUsername() {
return user.getUserName();
}
/**
* 判断用户账号是否过期
*/
@Override
public boolean isAccountNonExpired() {
return true;
}
/**
* 判断用户是否被锁定
*/
@Override
public boolean isAccountNonLocked() {
return true;
}
/**
* 判断该用户的认证凭证是否过期
*/
@Override
public boolean isCredentialsNonExpired() {
return true;
}
/**
* 用于判断用户是否启用
*/
@Override
public boolean isEnabled() {
return true;
}
}
由于认证的时候,存在自定义的登录接口,需要让 SpringSecurity 将其放行,使用户在不需要登录的时候也能够访问;同时,可以设置过滤器的前后位置(这里将JWT认证过滤器放在用户登录过滤器之前)
代码如下:
这里是用户登录验证的接口
/**
* 【用户的验证】
*/
@Service
public class LoginServiceImpl implements LoginService {
@Resource
private AuthenticationManager authenticationManager;
@Resource
private RedisCache redisCache;
@Override
public ResponseResult login(User user) {
//1.使用 authenticate 方法进行用户的验证
UsernamePasswordAuthenticationToken authenticationToken
= new UsernamePasswordAuthenticationToken(user.getUserName(),user.getPassword());
Authentication authenticateResult = authenticationManager.authenticate(authenticationToken);
//2.如果认证没通过,则进行提示
if(Objects.isNull(authenticateResult)){
throw new RuntimeException("用户名或密码错误!");
}
//3.如果认证通过,使用 userId 生成一个 jwt
LoginUser loginUser = (LoginUser) authenticateResult.getPrincipal(); //当前登录用户信息
Long userId = loginUser.getUser().getId();
String jwt = JwtUtil.createJWT(userId.toString()); //使用 jwt 工具类进行生成
HashMap<String, String> map = new HashMap<>();
map.put("token",jwt);
//4.把完整的用户信息存入 redis ,其中 userId 作为 key
redisCache.setCacheObject("login:"+userId,loginUser);
return new ResponseResult(200,"登录成功!",map);
}
}
这里是认证放行接口,同时设置了过滤器的前后顺序
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
/**
* 【这里是认证放行接口】
*/
@Bean
@Override
protected void configure(HttpSecurity http) throws Exception {
http
//关闭csrf保护机制
.csrf().disable()
//不通过Session获取SecurityContext,而是通过 Token 进行获取
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeRequests()
// 对于登录接口 允许匿名访问
.antMatchers("/user/login").anonymous()
// 除上面外的所有请求全部需要鉴权认证
.anyRequest().authenticated();
//将 jwt认证过滤器 放在 用户登录过滤器 之前
http.addFilterBefore(jwtAuthenticationTokenFilter,
UsernamePasswordAuthenticationFilter.class);
}
}
这里是 JWT认证过滤器,通过请求请求头中发送过来的 Token, 对 Token 中进行解析,从而取出其中的 userId;使用 userId 去 redis 中获取相应的 LoginUser 对象,最后存入 SecurityContextHolder 中(这里,Token 中存放的是用户 ID)
@Component
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {
@Resource
private RedisCache redisCache;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException {
//1.从请求头中获取 token
String token = request.getHeader("token");
//1.1 若 token 不存在则直接放行(token 不存在说明不需要认证)
if(!StringUtils.hasLength(token)){
chain.doFilter(request,response);
return;
}
//2.解析 token
String userId ;
try {
Claims claims = JwtUtil.parseJWT(token);
userId = claims.getSubject();
} catch (Exception e) {
e.printStackTrace();
throw new RuntimeException("token 非法");
}
//3.从 redis 中获取用户信息
String redisKey = "login:"+ userId;
LoginUser loginUser = redisCache.getCacheObject(redisKey);
if(Objects.isNull(loginUser)){
throw new RuntimeException("该用户不存在!");
}
//4.存入 SecurityContextHolder
//TODO 获取权限信息
UsernamePasswordAuthenticationToken authenticationToken
= new UsernamePasswordAuthenticationToken(loginUser,null,null);
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
//5.进行放行
chain.doFilter(request,response);
}
前面已经有了用户登录接口,所以现在需要退出登录接口
由于之前在 JwtAuthenticationTokenFilter 过滤器中,已经将登录成功的用户信息放入了 SecurityContextHolder 对象中,所以这里需要将其从中取出;
然后通过用户信息获取到用户 ID,最后在 Redis 中根据对应的用户 ID 删除对应用户的 Redis 缓存信息,这样就可以在下一次登录的时候使之前已经退出的用户 Token 失效,从而需要重新登录,才可访问其他需要授权的页面
/**
* 【用户退出登录】
*/
@Override
public ResponseResult logout() {
//1.由于用户信息已经保存到了 SecurityContextHolder 中
// 所以从 SecurityContextHolder 中获取用户 ID
UsernamePasswordAuthenticationToken authentication
= (UsernamePasswordAuthenticationToken) SecurityContextHolder.getContext().getAuthentication();
//1.1 通过 UserDetails 获取用户登录信息
LoginUser loginUser = (LoginUser) authentication.getPrincipal();
User user = loginUser.getUser();
Long userId = user.getId();
//2.根据用户 id 删除 redis 中对应的 key 所对应的 value 值
redisCache.deleteObject("login:"+userId);
return new ResponseResult(200,"退出登录成功!");
}
3、关于 SpringSecurity 中的授权
前文引入:
例如一个学校图书馆的管理系统,如果是普通学生登录就能看到借书还书相关的功能,不可能让他看到并且去使用添加书籍信息,删除书籍信息等功能;但是如果是一个图书馆管理员的账号登录了,应该就能看到并使用添加书籍信息,删除书籍信息等功能
所以说,设置权限这一项,对于一个完整的软件而言尤为重要......
第一步:首先在认证放行接口 SecurityConfig 处开启权限控制
@Configuration @EnableGlobalMethodSecurity(prePostEnabled = true) //【开启权限控制】 public class SecurityConfig extends WebSecurityConfigurerAdapter {...............
}
第二步:这里创建一个测试权限的接口,同时设置权限的信息
需要说明的是,@PreAuthorize 注解用于在方法上面设置权限的信息 ,SpEL 表达式定义权限规则,这里的表达式 hasAuthority('test')
表示该方法需要具有"test"权限
@RestController
public class HelloController {
@GetMapping("hello")
@PreAuthorize("hasAuthority('system:dept:list')") //设置权限信息
public String hello(){
return "hello";
}
}
第三步:我们需要重写 LoginUser(继承 UserDetals) 中的 getAuthorities() 方法,将继承 UserDetailService 的类 传过来的权限信息封装到 SimpleGrantedAuthority 对象中进行返回
这里是 UserDetailsServiceImpl 类(继承了 UserDetailsService 类),这里的用户权限信息先写死
@Slf4j
@Service
public class UserDetailsServiceImpl implements UserDetailsService {
@Resource
private UserMappper userMappper;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
LambdaQueryWrapper<User> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(User::getUserName,username);
//1.查询用户信息
User user = userMappper.selectOne(queryWrapper);
if(Objects.isNull(user)){
log.error("用户名或密码错误!");
throw new RuntimeException("用户名或密码错误!");
}
//2.将用户信息封装为 UserDetails 对象返回
ArrayList<String> list = new ArrayList<>(Arrays.asList("test","admin")); //这里权限信息先进行写死
return new LoginUser(user,list); //将用户以及权限信息传入 LoginUser 对象中
}
}
这里是 LoginUser 类(继承了 UserDetails 类 )
@Data
@NoArgsConstructor
public class LoginUser implements UserDetails {
private User user;
private List<String> permissions; //创建一个集合,用来封装权限信息
@JSONField(serialize = false) //敏感信息,让 JSON 字符串不包含该字段
private List<SimpleGrantedAuthority> authorities;
/**
* 获取权限信息
*/
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
//1.若不为空,则直接返回(说明之前已经存在权限信息)
if(authorities!=null){
return authorities;
}
//2.将 permission 中的权限信息封装到 GrantedAuthority 对象中进行返回
authorities = permissions.stream()
.map(new Function<String, SimpleGrantedAuthority>() {
@Override
public SimpleGrantedAuthority apply(String permission) {
SimpleGrantedAuthority simpleGrantedAuthority = new SimpleGrantedAuthority(permission);
return simpleGrantedAuthority;
}
}).collect(Collectors.toList());
return authorities;
}
@Override
public String getPassword() {
return user.getPassword();
}
@Override
public String getUsername() {
return user.getUserName();
}
/**
* 判断用户账号是否过期
*/
@Override
public boolean isAccountNonExpired() {
return true;
}
/**
* 判断用户是否被锁定
*/
@Override
public boolean isAccountNonLocked() {
return true;
}
/**
* 判断该用户的认证凭证是否过期
*/
@Override
public boolean isCredentialsNonExpired() {
return true;
}
/**
* 用于判断用户是否启用
*/
@Override
public boolean isEnabled() {
return true;
}
public LoginUser(User user, List<String> permissions) {
this.user = user;
this.permissions = permissions;
}
}
第四步:由于这时候 LoginUser 中的 getAuthorities() 重写方法已存在用户的权限信息,所以经过 JWT 认证过滤器的时候,需要将其存入 SecurityContextHolder 对象中并进行返回,因为在整个 SpringSecurity 框架中,这个对象是连接整个认证流程的上下文
//4.将用户信息存入 SecurityContextHolder
//4.1获取权限信息
Collection<? extends GrantedAuthority> authorities = loginUser.getAuthorities();
//4.2 将用户信息以及权限信息存入 SecurityContextHolder 对象中
UsernamePasswordAuthenticationToken authenticationToken
= new UsernamePasswordAuthenticationToken(loginUser,null,authorities);
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
//5.进行放行
chain.doFilter(request,response);
第五步:在用户登录退出接口处,使用该上下文对象,获取当前用户权限信息,来进行后续的操作
这里由于用户的权限信息是写死的,平时通常是动态获取的,所以我们选择从数据库中进行动态的获取权限信息
3.1 从数据库中查询用户的权限信息
这里使用的是 RBAC 模型,即基于角色的权限控制
如图所示:
这里使用角色的关联表将其他的数据表关联起来
由于存在关联表,所以需要进行多表联查
对应的 SQL 语句如下所示:
SELECT DISTINCT sm.perms
FROM sys_user_role sur #用户角色关联表 user_role
LEFT JOIN sys_role sr #角色表 role
ON sur.role_id = sr.id
LEFT JOIN sys_role_menu srm #角色权限关联表 role_menu
ON srm.role_id = sur.role_id
LEFT JOIN sys_menu sm #权限表 menu
ON sm.id = srm.menu_id
WHERE user_id = 2
AND sr.`status` = 0 AND sm.`status` = 0
MyBatis-Plus 对应的 xml 文件:
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd" >
<mapper namespace="MyPro.Mapper2.MenuMapper">
<!-- 根据用户 ID 获取用户的权限信息 -->
<select id="selectPermsByUserId" resultType="java.lang.String">
SELECT DISTINCT (sm.perms)
FROM sys_user_role sur
LEFT JOIN sys_role sr
ON sur.role_id = sr.id
LEFT JOIN sys_role_menu srm
ON srm.role_id = sur.role_id
LEFT JOIN sys_menu sm
ON sm.id = srm.menu_id
WHERE user_id = {#userId}
AND sr.`status` = 0 AND sm.`status` = 0
</select>
</mapper>
这里进行调用 Mapper 方法,动态的获取用户权限信息:
@Slf4j
@Service
public class UserDetailsServiceImpl implements UserDetailsService {
@Autowired
private UserMapper userMapper;
@Autowired
private MenuMapper menuMapper;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
LambdaQueryWrapper<User> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(User::getUserName,username);
//1.查询用户信息
User user = userMapper.selectOne(queryWrapper);
if(Objects.isNull(user)){
log.error("用户名或密码错误!");
throw new RuntimeException("用户名或密码错误!");
}
//2.将用户信息封装为 UserDetails 对象返回
// ArrayList<String> list = new ArrayList<>(Arrays.asList("test","admin")); //这里权限信息先进行写死
List<String> list = menuMapper.selectPermsByUserId(user.getId()); //这里进行动态的获取用户权限信息
return new LoginUser(user,list); //将用户以及权限信息传入 LoginUser 对象中
}
}
4、关于自定义失败处理
前言:
在SpringSecurity中,如果我们在认证或者授权的过程中出现了异常会被ExceptionTranslationFilter 捕获到;在 ExceptionTranslationFilter 中会去判断是认证失败还是授权失败出现的异常
如果是认证过程中出现的异常会被封装成 AuthenticationException 然后调用AuthenticationEntryPoint 对象的方法去进行异常处理
如果是授权过程中出现的异常会被封装成 AccessDeniedException 然后调用AccessDeniedHandler 对象的方法去进行异常处理
所以,我们需要进行自定义失败处理,以进行统一的异常处理
这里是【用户认证】的异常处理类:
@Component
public class AuthenticationEntryPointImpl implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
ResponseResult result = new ResponseResult(HttpStatus.UNAUTHORIZED.value(),"用户认证失败!");
String json = JSON.toJSONString(result);
//处理异常
WebUtils.renderString(response,json);
}
}
这里是【用户授权】的异常处理类:
@Component
public class AccessDeniedHandlerImpl implements AccessDeniedHandler {
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException exception) throws IOException, ServletException {
ResponseResult result = new ResponseResult(HttpStatus.UNAUTHORIZED.value(),"您的权限不足!");
String json = JSON.toJSONString(result);
//处理异常
WebUtils.renderString(response,json);
}
}
将上面的异常处理器在 Config 类中进行配置,让 SpringSecurity 框架使用自定义处理器:
protected void configure(HttpSecurity http) throws Exception {
http
//关闭csrf保护机制
.csrf().disable()
//不通过Session获取SecurityContext
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeRequests()
// 对于登录接口 允许匿名访问
.antMatchers("/user/login").anonymous()
// 除上面外的所有请求全部需要鉴权认证
.anyRequest().authenticated();
//将 jwt认证过滤器 放在 用户登录过滤器 之前
http.addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);
//这里是进行配置异常处理器
http.exceptionHandling()
.authenticationEntryPoint(authenticationEntryPoint) //用户认证
.accessDeniedHandler(accessDeniedHandler); //用户授权
}
5、跨域问题
前言:
浏览器出于安全的考虑,使用 XMLHttpRequest对象 发起 HTTP请求时必须遵守同源策略(要求源相同才能正常进行通信,即协议、域名、端口号都完全一致),否则就是跨域的HTTP请求,跨域默认情况下是被禁止的
前后端分离项目,前端项目和后端项目一般都不是同源的,所以肯定会存在跨域请求的问题
所以我们就要处理一下,让前端能进行跨域请求
这里对 SpringBoot 配置,进行跨域请求的配置:
@Configuration
public class CorsConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
// 设置允许跨域的路径
registry.addMapping("/**")
// 设置允许跨域请求的域名
.allowedOriginPatterns("*")
// 是否允许cookie
.allowCredentials(true)
// 设置允许的请求方式
.allowedMethods("GET", "POST", "DELETE", "PUT")
// 设置允许的header属性
.allowedHeaders("*")
// 跨域允许时间
.maxAge(3600);
}
}
当然,以上只是对 Spring 进行了配置跨域的请求,还需要对 SpringSecurity 进行跨域的配置
这里,在 Config 配置类中进行跨域的配置:
@Override
protected void configure(HttpSecurity http) throws Exception {
http
//关闭csrf保护机制
.csrf().disable()
//不通过Session获取SecurityContext,而是通过Token进行获取
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeRequests()
// 对于登录接口 允许匿名访问
.antMatchers("/user/login").anonymous()
// 除上面外的所有请求全部需要鉴权认证
.anyRequest().authenticated();
//将 jwt认证过滤器 放在 用户登录过滤器 之前
http.addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);
//这里是进行配置异常处理器
http.exceptionHandling()
.authenticationEntryPoint(authenticationEntryPoint) //用户认证
.accessDeniedHandler(accessDeniedHandler); //用户授权
//允许跨域
http.cors();
}
}
注意:如果使用 PostMan 进行测试是不会成功的,因为它的本质还是在 “同源策略” 中