Spring Security 学习笔记

94 阅读12分钟

Spring Security

免责声明:本文章仅做个人学习记录使用,内容或许会有错误,还请各位看官评论赐教不胜感激!代码均摘自若依开源项目

一.简介

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

1.1 认证

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

1.2 授权

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

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

二.流程梳理

2.1 登录效验流程

登录效验流程-导出.png

2.2 Security流程

SpringSecurity的原理就是一个过滤器链,内部包含了提供各种功能的过滤器,下图整理出一些主要的过滤器。Security流程-导出.png SpringSecurity的认证流程 认证流程-导出.png 概念速查:

  • 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 思路分析

登录验证流程图-导出.png

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);
    }