Spring Security

Spring Security

免责声明:此文档仅用于本人记录使用,内容会有差错,还请各位看官多加指点!

一.简介

Spring Security是Spring项目组提供的安全服务框架,核心功能包括认证授权。它为系统提供了声明式安全访问控制功能,减少了为系统安全而编写大量重复代码的工作。

1.1 认证

认证即系统判断用户的身份是否合法,合法可继续访问,不合法则拒绝访问。常见的用户身份认证方式有:用户名密码登录、二维码登录、手机短信登录、脸部识别认证、指纹认证等方式。认证是为了保护系统的隐私数据与资源,用户的身份合法才能访问该系统的资源。

1.2 授权

授权即认证通过后,根据用户的权限来控制用户访问资源的过程,拥有资源的访问权限则正常访问,没有权限则拒绝访问。比如在一些视频网站中,普通用户登录后只有观看免费视频的权限,而VIP用户登录后,网站会给该用户提供观看VIP视频的权限。认证是为了保证用户身份的合法性,授权则是为了更细粒度的对隐私数据进行划分,控制不同的用户能够访问不同的资源。

举个例子:认证是公司大门识别你作为员工能进入公司,而授权则是由于你作为公司会计可以进入财务室,查看账目,处理财务数据

二.流程梳理

2.1 登录效验流程

2.2 Security流程

SpringSecurity的原理就是一个过滤器链,内部包含了提供各种功能的过滤器,下图整理出一些主要的过滤器。

 

SpringSecurity的认证流程

 

概念速查:

  • Authentication接口:它的实现类,表示当前访问系统的用户,封装了用户相关信息

  • AuthenticationManager接口:定义了认证Authentication的方法

  • UserDetailsService接口:加载用户特定数据的核心接口。里面定义了一个根据用户名查询用户信息的方法

  • UserDetails接口:提供核心用户信息。通过UserDetailsService根据用户名获取处理的用户信息要封装成UserDetails对象返回。然后将这些信息封装到Authentication对象中

  • SecurityContextHolder对象:上下对象,其他方法中用用户信息的时候就用这个东西获取。

    个人理解:从上面的认证流程中总结,基本就是把入参的用户名密码封装到Authentication对象里,中间通过一系列的骚操作处理,处理成UserDetails对象,然后在把UserDetails对象封装成Authentication对象返回的过程

    1:上述流程是Security框架默认的处理流程,数据对比是获取的内存中的,真正的开发中我们要在数据库中获取用户信息做对比,主要处理的就是UserDetailsService实现类里面做对比的内容

    2:输入密码和验证通过之后,都会到AbstractAuthenticationProcessingFilter实现类中进行处理,真正开发中不能用他提供的登录页面,并且当登录成功之后我们还有一些其他的操作要做,所以我们要重新搞一个自己的AbstractAuthenticationProcessingFilter实现类

    总结而言就是,自己重写头尾两个实现类来搞自己的业务逻辑。

三.快速开始

在pom中引入Security的Starter

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

Tips:引入之后,启动项目会跳到一个他默认的页面,这个页面由security提供,默认的用户名是user密码会在启动的时候控制台输出

四.功能实现

4.1 思路分析

 

4.1.1 登录

  1. 自定义登录接口

    • 不用UsernamePasswordAuthenticationFilter,自己写一个,用来获表单提交的用户名密码

    • 调用AuthenticationManager中的authenticate方法让Filter链走下去

    • 认证通过后,使用JWT封装Token,数据存入Redis

  2. 自定义UserDetailsService实现类

    • 查库,检查用户名密码是否有效

4.1.2 校验

  1. 定义Jwt认证过滤器

    • 获取、解析Token

    • 根据Token从Redis中获取用户相关信息

    • 存入SecurityContextHolder对象方便其他内容获取用户信息

4.1.3 配置类

  1. 创建一个类,继承WebSecurityConfigurerAdapter

  2. 注入AuthenticationManager对象(登录接口中会有用到这个对象,所以得注入到容器中)

  3. 注入密码加密方式(我也不知道为啥非得写这里,反正老师和若依都这么写的,那就这么写吧)

  4. 重写configure(HttpSecurity httpSecurity)方法,配置拦截相关内容(具体的配置内容可以参考代码)

4.1.4 加密

  1. 加密方式

    • 注入BCryptPasswordEncoder替换默认的PasswordEncoder实现类的加密方式(当然也可以自定义加密方式,只要实现PasswordEncoder并且注入到Spring容器中就行)

Tips:Security默认的加密方式是PasswordEncoder:{id}password。他会根据id去判定加密方式,但是一般我们都会改掉,BCryptPasswordEncoder也是Security提供的一种加密方式

4.1.5 退出

  1. 获取Token,然后删掉Resid中的信息

4.1.6 权限

  1. 开启权限使用注解@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true)

  2. 自定义权限方式

4.2 功能实现

Security配置类

/**
* spring security配置
*
* @author system
*/
//开启权限注解
@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter
{
   /**
    * 自定义用户认证逻辑
    */
   @Autowired
   private UserDetailsService userDetailsService;
   
   /**
    * 认证失败处理类
    */
   @Autowired
   private AuthenticationEntryPointImpl unauthorizedHandler;

   /**
    * 退出处理类
    */
   @Autowired
   private LogoutSuccessHandlerImpl logoutSuccessHandler;

   /**
    * token认证过滤器
    */
   @Autowired
   private JwtAuthenticationTokenFilter authenticationTokenFilter;
   
   /**
    * 跨域过滤器
    */
   @Autowired
   private CorsFilter corsFilter;

   /**
    * 允许匿名访问的地址
    */
   @Autowired
   private PermitAllUrlProperties permitAllUrl;

   /**
    * 解决 无法直接注入 AuthenticationManager
    *
    * @return
    * @throws Exception
    */
   @Bean
   @Override
   public AuthenticationManager authenticationManagerBean() throws Exception
  {
       return super.authenticationManagerBean();
  }
 
   /**
    * 注入BCryptPasswordEncoder(强散列哈希加密实现)替换默认的加密方式
    */
   @Bean
   public BCryptPasswordEncoder bCryptPasswordEncoder()
  {
       return new BCryptPasswordEncoder();
  }
 
   @Override
   protected void configure(HttpSecurity httpSecurity) throws Exception
  {
       // 注解标记允许匿名访问的url
       ExpressionUrlAuthorizationConfigurer<HttpSecurity>.ExpressionInterceptUrlRegistry registry = httpSecurity.authorizeRequests();
       permitAllUrl.getUrls().forEach(url -> registry.antMatchers(url).permitAll());

       httpSecurity
               // CSRF禁用,因为不使用session
              .csrf().disable()
               // 禁用HTTP响应标头
              .headers().cacheControl().disable().and()
               // 认证失败处理类
              .exceptionHandling().authenticationEntryPoint(unauthorizedHandler).and()
               // 基于token,所以不需要session
              .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).and()
               // 过滤请求
              .authorizeRequests()
               // 对于登录login 注册register 验证码captchaImage 允许匿名访问
              .antMatchers("/login", "/register", "/captchaImage").permitAll()
               // 静态资源,可匿名访问
              .antMatchers(HttpMethod.GET, "/", "/*.html", "/**/*.html", "/**/*.css", "/**/*.js", "/profile/**").permitAll()
              .antMatchers("/swagger-ui.html", "/swagger-resources/**", "/webjars/**", "/*/api-docs", "/druid/**").permitAll()
               // 除上面外的所有请求全部需要鉴权认证
              .anyRequest().authenticated().and()
               //禁用X-Frame-Options头,从而允许网页在iframe中嵌套显示
              .headers().frameOptions().disable();
       // 添加Logout filter
       httpSecurity.logout().logoutUrl("/logout").logoutSuccessHandler(logoutSuccessHandler);
       // 添加JWT filter
       httpSecurity.addFilterBefore(authenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);
       // 添加CORS 跨域filter
       httpSecurity.addFilterBefore(corsFilter, JwtAuthenticationTokenFilter.class);
       httpSecurity.addFilterBefore(corsFilter, LogoutFilter.class);
  }



   /**
    * 身份认证接口
    */
   @Override
   protected void configure(AuthenticationManagerBuilder auth) throws Exception
  {
       auth.userDetailsService(userDetailsService).passwordEncoder(bCryptPasswordEncoder());
  }
}

❓❓❓:这里我有个疑惑,已经不走Securaty的UsernamePasswordAuthenticationFilter了,为什么还要把authenticationTokenFilter添加在这个之前,我们之前登录逻辑到底是怎么走的?

猜测答案:在登录功能中,之所以能直接访问到登录接口,是因为配置了Securaty路径放行,放行不代表就不走UsernamePasswordAuthenticationFilter了,所以该走还是走,只不过给放行了而已,所以验证Token的操作还是要在UsernamePasswordAuthenticationFilter之前执行


编写登录接口实现类(Controller中调用这个方法就好了)

/**
    * 登录验证
    *
    * @param username 用户名
    * @param password 密码
    * @param code 验证码
    * @param uuid 唯一标识
    * @return 结果
    */
public String login(String username, String password, String code, String uuid)
{
 // 用户验证
 Authentication authentication = null;
 try{
   //loadUserByUsername需要一个Authentication类型的入参,所以用这东西封装一下,username和password
   UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(username, password);
   //把Authentication扔给全局域对象,以便在其他地方获取
   AuthenticationContextHolder.setContext(authenticationToken);
   // 该方法会去调用UserDetailsServiceImpl.loadUserByUsername 让Filter链走下去
   authentication = authenticationManager.authenticate(authenticationToken);
}
 //没查到用户,捕获UserDetailsServiceImpl.loadUserByUsername 抛出的异常
 catch (Exception e){
   if (e instanceof BadCredentialsException){throw new UserPasswordNotMatchException();}
   else{throw new ServiceException(e.getMessage());}
}
 //清除Authentication防止内存泄露
 finally{AuthenticationContextHolder.clearContext();}
 //在全局对象中获取user对象并转换成LoginUser对象,因为在UserDetailsService中是用这个对象封装进去的
 LoginUser loginUser = (LoginUser) authentication.getPrincipal();
 // 获取随机UUID,使用性能更好的ThreadLocalRandom生成UUID 用于 生成token
 String token = IdUtils.fastUUID();
 loginUser.setToken(token);
 //封装内容:把用户的浏览器系统等相关信息封装到loginUser对象中
 setUserAgent(loginUser);
 //封装内容:存入redis 并设置失效时间
 refreshToken(loginUser);
 Map<String, Object> claims = new HashMap<>();
 claims.put(Constants.LOGIN_USER_KEY, token);
 String token = Jwts.builder()
  .setClaims(claims)
  .signWith(SignatureAlgorithm.HS512, secret).compact();
 return token;
}

Tips:AuthenticationContextHolder的实现是依赖于ThreadLocal,所以在每个请求结束之后一定要清理掉,否则就会造成内存泄露

为何会内存泄露请学习ThreadLocal相关内容


自定义UserDetailsService实现类查库

/**
* 用户验证处理
*
* @author system
*/
@Service
public class UserDetailsServiceImpl implements UserDetailsService
{
   private static final Logger log = LoggerFactory.getLogger(UserDetailsServiceImpl.class);

   @Autowired
   //注入自己写的用户信息的接口
   private ISysUserService userService;

   @Autowired
//注入自己写的查权限的接口
   private SysPermissionService permissionService;

   @Override
   public UserDetails loadUserByUsername(String username)
  {
       //查库获取用户信息
       SysUser user = userService.selectUserByUserName(username);
       //没查到用户,就是信息有误,抛出异常
       if (StringUtils.isNull(user))
      {
           log.info("登录用户不存在.");
           throw new ServiceException("登录用户不存在");
      }
       //如果用户存在 把数据封装成UserDetails对象
       return createLoginUser(user);
  }

   public UserDetails createLoginUser(SysUser user)
  {
       return new LoginUser(user.getUserId(), user.getDeptId(), user, permissionService.getMenuPermission(user));
  }
}

创建自己的登录对象,实现UserDetails

/**
* 登录用户身份权限
*
* @author system
*/
@Data
public class LoginUser implements UserDetails
{
   private static final long serialVersionUID = 1L;

   /**
    * 用户ID
    */
   private Long userId;

   /**
    * 部门ID
    */
   private Long deptId;

   /**
    * 用户唯一标识
    */
   private String token;

   /**
    * 登录时间
    */
   private Long loginTime;

   /**
    * 过期时间
    */
   private Long expireTime;

   /**
    * 登录IP地址
    */
   private String ipaddr;

   /**
    * 登录地点
    */
   private String loginLocation;

   /**
    * 浏览器类型
    */
   private String browser;

   /**
    * 操作系统
    */
   private String os;

   /**
    * 权限列表
    */
   private Set<String> permissions;

   /**
    * 用户信息
    */
   private SysUser user;

   public LoginUser()
  {
  }

   public LoginUser(SysUser user, Set<String> permissions)
  {
       this.user = user;
       this.permissions = permissions;
  }

   public LoginUser(Long userId, Long deptId, SysUser user, Set<String> permissions)
  {
       this.userId = userId;
       this.deptId = deptId;
       this.user = user;
       this.permissions = permissions;
  }

   @JSONField(serialize = false)
   @Override
   public String getPassword()
  {
       return user.getPassword();
  }

   @Override
   public String getUsername()
  {
       return user.getUserName();
  }

   /**
    * 账户是否未过期,过期无法验证
    */
   @JSONField(serialize = false)
   @Override
   public boolean isAccountNonExpired()
  {
       return true;
  }

   /**
    * 指定用户是否解锁,锁定的用户无法进行身份验证
    *
    * @return
    */
   @JSONField(serialize = false)
   @Override
   public boolean isAccountNonLocked()
  {
       return true;
  }

   /**
    * 指示是否已过期的用户的凭据(密码),过期的凭据防止认证
    *
    * @return
    */
   @JSONField(serialize = false)
   @Override
   public boolean isCredentialsNonExpired()
  {
       return true;
  }

   /**
    * 是否可用 ,禁用的用户不能身份验证
    *
    * @return
    */
   @JSONField(serialize = false)
   @Override
   public boolean isEnabled()
  {
       return true;
  }

   @Override
   public Collection<? extends GrantedAuthority> getAuthorities()
  {
       return null;
  }
}

Tips:注意下面@Override的getUsername等方法,在Security框架中,使用UserDetails实现类对象的时候,是根据这个来获取对象的相关信息的,所以要return user.getUserName();这样把相关的信息穿进去,框架获取的全是null


JWT认证过滤器

/**
* token过滤器 验证token有效性
* @param chain 过滤器链
* @author system
*/
@Component
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter
{
   @Autowired
   private TokenService tokenService;

   @Override
   protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
           throws ServletException, IOException
  {
     //从request中获取Token
       LoginUser loginUser = tokenService.getLoginUser(request);
     //如果有Token进行处理
       if (StringUtils.isNotNull(loginUser) && StringUtils.isNull(SecurityUtils.getAuthentication()))
      {
         //重新刷新Redis中Token有限期
           tokenService.verifyToken(loginUser);
         //把User信息封装到authenticationToken对象中,以备后面Security的过滤器使用
           UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(loginUser, null, loginUser.getAuthorities());
           authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
           SecurityContextHolder.getContext().setAuthentication(authenticationToken);
      }
       //如果没有Tonek直接放行,去走后面的过滤器链
       chain.doFilter(request, response);
  }
}

Tips

  1. 这是一个过滤器,在之前的JavaWab中我们是去实现一个Filter接口的,但是那个有点问题(在不同Servelet版本中,有时候会一个请求过来过滤器会被调用多次),现在使用Spring为我们提供的Fielter实现类OncePerRequestFilter就行了。

  2. 用户的数据已经被放置在SecurityContextHolder中,后续过滤器就可以获取到用户信息了。同时在其他的功能中也可以在SecurityContextHolder中拿去用户信息了。


退出处理类(没啥说的,获取Token,删掉Redis中的数据)

/**
* 自定义退出处理类 返回成功
*
* @author system
*/
@Configuration
public class LogoutSuccessHandlerImpl implements LogoutSuccessHandler
{
   @Autowired
   private TokenService tokenService;

   /**
    * 退出处理
    *
    * @return
    */
   @Override
   public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication)
           throws IOException, ServletException
  {
     //解析Token中的信息
       LoginUser loginUser = tokenService.getLoginUser(request);
       if (StringUtils.isNotNull(loginUser))
      {
           String userName = loginUser.getUsername();
           // 删除Redis中的用户缓存记录
           tokenService.delLoginUser(loginUser.getToken());
      }
       ServletUtils.renderString(response, JSON.toJSONString(AjaxResult.success("退出成功")));
  }
}

权限方法

/**
* RuoYi首创 自定义权限实现,ss取自SpringSecurity首字母
*
* @author system
*/
@Service("ss")
public class PermissionService
{
   /** 所有权限标识 */
   private static final String ALL_PERMISSION = "*:*:*";

   /** 管理员角色权限标识 */
   private static final String SUPER_ADMIN = "admin";

   private static final String ROLE_DELIMETER = ",";

   private static final String PERMISSION_DELIMETER = ",";

   /**
    * 验证用户是否具备某权限
    *
    * @param permission 权限字符串
    * @return 用户是否具备某权限
    */
   public boolean hasPermi(String permission)
  {
       if (StringUtils.isEmpty(permission))
      {
           return false;
      }
       LoginUser loginUser = SecurityUtils.getLoginUser();
       if (StringUtils.isNull(loginUser) || CollectionUtils.isEmpty(loginUser.getPermissions()))
      {
           return false;
      }
       PermissionContextHolder.setContext(permission);
       return hasPermissions(loginUser.getPermissions(), permission);
  }

   /**
    * 验证用户是否不具备某权限,与 hasPermi逻辑相反
    *
    * @param permission 权限字符串
    * @return 用户是否不具备某权限
    */
   public boolean lacksPermi(String permission)
  {
       return hasPermi(permission) != true;
  }

   /**
    * 验证用户是否具有以下任意一个权限
    *
    * @param permissions 以 PERMISSION_DELIMETER 为分隔符的权限列表
    * @return 用户是否具有以下任意一个权限
    */
   public boolean hasAnyPermi(String permissions)
  {
       if (StringUtils.isEmpty(permissions))
      {
           return false;
      }
       LoginUser loginUser = SecurityUtils.getLoginUser();
       if (StringUtils.isNull(loginUser) || CollectionUtils.isEmpty(loginUser.getPermissions()))
      {
           return false;
      }
       PermissionContextHolder.setContext(permissions);
       Set<String> authorities = loginUser.getPermissions();
       for (String permission : permissions.split(PERMISSION_DELIMETER))
      {
           if (permission != null && hasPermissions(authorities, permission))
          {
               return true;
          }
      }
       return false;
  }

   /**
    * 判断用户是否拥有某个角色
    *
    * @param role 角色字符串
    * @return 用户是否具备某角色
    */
   public boolean hasRole(String role)
  {
       if (StringUtils.isEmpty(role))
      {
           return false;
      }
       LoginUser loginUser = SecurityUtils.getLoginUser();
       if (StringUtils.isNull(loginUser) || CollectionUtils.isEmpty(loginUser.getUser().getRoles()))
      {
           return false;
      }
       for (SysRole sysRole : loginUser.getUser().getRoles())
      {
           String roleKey = sysRole.getRoleKey();
           if (SUPER_ADMIN.equals(roleKey) || roleKey.equals(StringUtils.trim(role)))
          {
               return true;
          }
      }
       return false;
  }

   /**
    * 验证用户是否不具备某角色,与 isRole逻辑相反。
    *
    * @param role 角色名称
    * @return 用户是否不具备某角色
    */
   public boolean lacksRole(String role)
  {
       return hasRole(role) != true;
  }

   /**
    * 验证用户是否具有以下任意一个角色
    *
    * @param roles 以 ROLE_NAMES_DELIMETER 为分隔符的角色列表
    * @return 用户是否具有以下任意一个角色
    */
   public boolean hasAnyRoles(String roles)
  {
       if (StringUtils.isEmpty(roles))
      {
           return false;
      }
       LoginUser loginUser = SecurityUtils.getLoginUser();
       if (StringUtils.isNull(loginUser) || CollectionUtils.isEmpty(loginUser.getUser().getRoles()))
      {
           return false;
      }
       for (String role : roles.split(ROLE_DELIMETER))
      {
           if (hasRole(role))
          {
               return true;
          }
      }
       return false;
  }

   /**
    * 判断是否包含权限
    *
    * @param permissions 权限列表
    * @param permission 权限字符串
    * @return 用户是否具备某权限
    */
   private boolean hasPermissions(Set<String> permissions, String permission)
  {
       return permissions.contains(ALL_PERMISSION) || permissions.contains(StringUtils.trim(permission));
  }
}

这里若依直接把权限相关内容封装到了RequestContextHolder里面


权限使用示例

    /**
* 获取菜单列表
*/
@PreAuthorize("@ss.hasPermi('system:menu:list')")
@GetMapping("/list")
public AjaxResult list(SysMenu menu)
{
List<SysMenu> menus = menuService.selectMenuList(menu, getUserId());
return success(menus);
}
 
 
 
 
 
posted @ 2023-08-07 16:12  抓哇攻城狮  阅读(123)  评论(0)    收藏  举报