文章目录

  • 前言
  • 一、Spring Security的概念
  • 二、Spring Security的实现原理
  • 1.过滤器链的各个进行说明:
  • 2.简易流程概述:
  • 3. 具体认证流程
  • 三 、springsecurity使用redis实现单点登录
  • 四 、测试认证服务器的功能
  • 五、认证服务器也可以是资源服务器


前言

Spring Security是一个当前流行的安全框架,它不仅实现了访问资源(crud)权限的控制,还可以基于它可以实现单点登陆认证业务.
本文主要介绍Spring Security的概念,认证及权限控制原理,以及单点登录的实现.

一、Spring Security的概念

Spring Security是一个能够为基于Spring的企业应用系统提供声明式的安全访问控制解决方案的安全框架(简单说是对访问权限进行控制的一个框架)。它提供了一组可以在Spring应用上下文中配置的Bean,充分利用了Spring IoC,DI(控制反转Inversion of Control ,DI:Dependency Injection 依赖注入)和AOP(面向切面编程)功能,为应用系统提供声明式的安全访问控制功能,减少了为企业系统安全控制编写大量重复代码的工作。

二、Spring Security的实现原理

Spring Security对Web安全性的支持大量地依赖于Servlet过滤器。通过这些过滤器拦截进入请求,判断是否已经登录认证且具访问对应请求的权限。

Spring Security 执行流程图:

spring security6 springsecurity6单点登录_微服务


要完成访问控制,Spring Security至少需要四个拦截器(调度器、认证管理器、权限资源关联器、访问决策器)进行配合完成:

SpringSecurity 采用的是责任链的设计模式,它有一条很长的过滤器链。

1.过滤器链的各个进行说明:

  1. WebAsyncManagerIntegrationFilter:将 Security 上下文与 Spring Web 中用于处理异步请求映射的 WebAsyncManager 进行集成。
  2. SecurityContextPersistenceFilter:在每次请求处理之前将该请求相关的安全上下文信息加载到 SecurityContextHolder 中,然后在该次请求处理完成之后,将 SecurityContextHolder 中关于这次请求的信息存储到一个“仓储”中,然后将 SecurityContextHolder 中的信息清除,例如在Session中维护一个用户的安全信息就是这个过滤器处理的。
  3. HeaderWriterFilter:用于将头信息加入响应中。
  4. CsrfFilter:用于处理跨站请求伪造。
  5. LogoutFilter:用于处理退出登录。
  6. UsernamePasswordAuthenticationFilter:用于处理基于表单的登录请求,从表单中获取用户名和密码。默认情况下处理来自 /login 的请求。从表单中获取用户名和密码时,默认使用的表单 name 值为 username 和 password,这两个值可以通过设置这个过滤器的usernameParameter 和 passwordParameter 两个参数的值进行修改。
  7. DefaultLoginPageGeneratingFilter:如果没有配置登录页面,那系统初始化时就会配置这个过滤器,并且用于在需要进行登录时生成一个登录表单页面。
  8. BasicAuthenticationFilter:检测和处理 http basic 认证。
  9. RequestCacheAwareFilter:用来处理请求的缓存。
  10. SecurityContextHolderAwareRequestFilter:主要是包装请求对象request。
  11. AnonymousAuthenticationFilter:检测 SecurityContextHolder 中是否存在 Authentication 对象,如果不存在为其提供一个匿名 Authentication。
  12. SessionManagementFilter:管理 session 的过滤器
  13. ExceptionTranslationFilter:处理 AccessDeniedException 和 AuthenticationException 异常。
  14. FilterSecurityInterceptor:可以看做过滤器链的出口。
  15. RememberMeAuthenticationFilter:当用户没有登录而直接访问资源时, 从 cookie 里找出用户的信息, 如果 Spring Security 能够识别出用户提供的remember me cookie, 用户将不必填写用户名和密码, 而是直接登录进入系统,该过滤器默认不开启。

2.简易流程概述:

  1. 客户端发起一个请求,进入 Security 过滤器链。
  2. 当到 LogoutFilter 的时候判断是否是登出路径,如果是登出路径则到 logoutHandler ,如果登出成功则到 logoutSuccessHandler 登出成功处理,如果登出失败则由 ExceptionTranslationFilter ;如果不是登出路径则直接进入下一个过滤器。
  3. 当到 UsernamePasswordAuthenticationFilter*( 该对象是 Authentication 接口的实现类,该类有两个构造器,一个用于封装前端请求传入的未认证的用户信息,一个用于封装认证成功后的用户信息) 的时候判断是否为登录路径,如果是,则进入该过滤器进行登录操作,如果登录失败则到 AuthenticationFailureHandler 登录失败处理器处理,如果登录成功则到 AuthenticationSuccessHandler 登录成功处理器处理,如果不是登录请求则不进入该过滤器。
  4. 当到 FilterSecurityInterceptor 的时候会拿到 uri ,根据 uri 去找对应的鉴权管理器,鉴权管理器做鉴权工作,鉴权成功则到 Controller 层否则到 AccessDeniedHandler 鉴权失败处理器处理。

3. 具体认证流程

spring security6 springsecurity6单点登录_微服务_02

  1. 用户发起登录请求,通过Security 过滤器链,到达UsernamePasswordAuthenticationFilter 过滤器.
  2. UsernamePasswordAuthenticationFilter过滤器的 attemptAuthentication() 方法将未认证的 Authentication 对象传入ProviderManager 类的 authenticate() 方法进行身份认证。ProviderManager 是 AuthenticationManager 接口的实现类,该接口是认证相关的核心接口,也是认证的入口。在实际开发中,我们可能有多种不同的认证方式,例如:用户名+ 密码、邮箱+密码、手机号+验证码等,而这些认证方式的入口始终只有一个,那就是 AuthenticationManager。
  3. AuthenticationManager接口的常用实现类 ProviderManager 内部会维护一个 List 列表,存放多种认证方式,实际上这是委托者模式 (Delegate)的应用。每种认证方式对应着一个 AuthenticationProvider.
  4. AuthenticationManager 根据认证方式的不同(根据传入的 Authentication 类型判断)委托对应的 AuthenticationProvider(实际上使用的是他的实现类DaoAuthenticationProvider) 进行用户认证。认证时需要UserDetailsService接口的实现类来传递用户信息.
  5. 自定义类XxUserDetailService实现UserDetailsService接口和其loadUserByUsername方法,这个方法根据用户输入的用户名,从数据库里面(常用微服务远程访问的方式)获取该用户的所有权限信息(统称用户信息)。MyUserDetailService拿到用户信息后,传给AuthenticationProvider(实际上使用的是他的实现类DaoAuthenticationProvider) 对比用户的密码(即验证用户),如果通过了,那么相当于通过了AuthenticationProcessingFilter拦截器,也就是登录验证通过。

三 、springsecurity使用redis实现单点登录

  1. 创建web安全配置类
    这个类是单体项目中经常使用到的类,但现在登录走的是Oauth2的流程,因此它只需要负责创建一些对象以及配置一下放行和认证规则就行,放行loginController下的所有方法是因为登录不需要被拦截。
/**
 * Security 配置类
 */
@Configuration
@EnableWebSecurity
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {


    // 初始化密码编码器,用BCryptPasswordEncoder加密密码
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    // 初始化认证管理对象,密码模式需要
    @Bean
    @Override
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }

    // 放行和认证规则
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.csrf().disable()
                .authorizeRequests()
                // 放行的请求
                .antMatchers("/loginController/**").permitAll()
                .and()
                .authorizeRequests()
                // 其他请求必须认证才能访问
                .anyRequest().authenticated();
    }

}
  1. 创建redis仓库配置类
    因为token信息和用户信息需要放到redis中存储,因此配置一下redis仓库
@Configuration
public class RedisTokenStoreConfig {
    // 注入 Redis 连接工厂
    @Autowired
    private RedisConnectionFactory redisConnectionFactory;
    // 初始化RedisTokenStore 用于将 token 存储至 Redis
    @Bean
    public RedisTokenStore redisTokenStore() {
        RedisTokenStore redisTokenStore = new RedisTokenStore(redisConnectionFactory);
        redisTokenStore.setPrefix("TOKEN:"); // 设置key的层级前缀,方便查询
        return redisTokenStore;
    }
}
  1. 创建UserDetailsService类
    这里为了更专注单点登录的编写,就不连接数据库了,写死数据,用户名为yuki,密码为123456,他的角色是TEACHER,登录时要填写正确的用户名和密码。
@Component
public class CustomerUserDetailService implements UserDetailsService {
    @Autowired
    private PasswordEncoder passwordEncoder;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        return User.withUsername("yuki").password(passwordEncoder.encode("123456")).roles("TEACHER").build();
    }
}
  1. 创建认证服务器配置类
    有了这三个文件,就可以去创建认证服务器的配置文件了,代码都有详细的注释,比较重要的是客户端其实只需要一个,放在内存中获取数据库中都可以,授权类型除了密码类型还加上刷新令牌类型,刷新令牌可以在访问令牌过期后重新获取访问令牌,下面会用到,他的默认过期时间是30天,也可以在客户端信息处自己配置。
    使用密码模式必须在endpoints处配置authenticationManager,使用刷新令牌也必须配置UserDetailsService对象,这都是TokenEndpoint类的/oauth/token控制器方法的源码决定的,只是使用的话不需要太纠结,因为不写就会报错,再配置上redisTokenStore即可。
    security处需要开启checkTokenAccess的权限,默认是禁止访问的,要改为permitAll()允许所有人访问,这样资源服务器才可以带着token来访问认证服务器校验用户是否登录以及获取用户的权限等信息。tokenKeyAccess如果使用jwt实现单点登录的话是需要开启的,它的作用的获取公钥来解密jwt的token信息,现在采用redis来实现,不开启也可。
@Configuration
//开启授权服务器
@EnableAuthorizationServer
public class AuthorizationServerConfig extends AuthorizationServerConfigurerAdapter {
    @Autowired
    private PasswordEncoder passwordEncoder;
    @Autowired
    private AuthenticationManager authenticationManager;
    @Autowired
    private RedisTokenStore redisTokenStore;
    @Autowired
    private UserDetailsService CustomerUserDetailService;
    /**
     * 配置被允许访问此认证服务器的客户端信息
     * 1.内存方式
     * 2. 数据库方式
     * @param clients
     * @throws Exception
     */
    @Override
    public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
        //暂时先放到内存中
        clients.inMemory()
                //配置客户端id
                .withClient("WebClient")
                //配置客户端密钥
                .secret(passwordEncoder.encode("123456"))
                //配置授权范围
                .scopes("all")
                //配置访问令牌过期时间
                .accessTokenValiditySeconds(60*100)
                //配置授权类型
                .authorizedGrantTypes("password","refresh_token");
    }
    //参数名称叫授权服务器端点配置器
    @Override
    public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
        // password 要这个 AuthenticationManager 实例
        endpoints.authenticationManager(authenticationManager)
                //使用redis方式管理令牌
                .tokenStore(redisTokenStore)
                //启动刷新令牌需要在此处指定UserDetailsService
                .userDetailsService(CustomerUserDetailService);
    }
    @Override
    public void configure(AuthorizationServerSecurityConfigurer security) throws Exception {
        // 开启/oauth/check_token,作用是资源服务器会带着令牌到授权服务器中检查令牌是否正确,然后如果正确授权服务器会给资源服务器返回用户的信息
        security.checkTokenAccess("permitAll()");
        // 认证后可访问 /oauth/token_key, 默认拒绝访问,作用是获取jwt公钥用来解析jwt
//        security.tokenKeyAccess("isAuthenticated()");
    }
}

四 、测试认证服务器的功能

下面的操作都还是在认证服务器中进行,在进行之前先要在配置文件中配置好redis的信息,因为登录要采用redis来做

server.port=9050
        
#配置单节点的redis服务
spring.redis.host=127.0.0.1
spring.redis.database=0
spring.redis.port=6379

创建LoginController类
这里说一下GETTOKENURL是springsecurity为我们提供的一个方法,我们带着特定的参数访问它就可以获取到访问令牌以及刷新令牌了。

@RestController
@RequestMapping("/loginController")
@Slf4j
public class LoginController {

    @Autowired
    private RestTemplate restTemplate;

    @Autowired
    private RedisOperator redisOperator;

    private static final String REDIS_USER_CODEKEY = "verifyCode";

    private static final String GETTOKENURL = "http://localhost:9050/oauth/token";


    //用户登录接口
    @PostMapping("/login")
    public Result login(String username,String password,String codeKey,String codeKeyIndex){

        //通过redis检测验证码是否正确
        String verifyCode = redisOperator.get(REDIS_USER_CODEKEY + ":" + codeKeyIndex);
        log.info("接收到验证码: "+codeKey);
        log.info("verifyCode: "+verifyCode);
        if (!codeKey.equals(verifyCode)){
            return new Result(HttpServletResponse.SC_FORBIDDEN,null,"验证码不正确");
        }

        // 构建请求头
        HttpHeaders headers = new HttpHeaders();
        headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
        // 构建请求体(请求参数)
        MultiValueMap<String,Object> paramsMap=new LinkedMultiValueMap<>();
        paramsMap.add("username", username);
        paramsMap.add("password", password);
        paramsMap.add("grant_type", "password");

        //利用密码模式到sso授权服务器中拿到access_token和refresh_token,因为是同一个项目的模块,可以使用密码模式
        //在请求头中带上客户端的账号密码
        HttpEntity<MultiValueMap<String, Object>> entity = new HttpEntity<>(paramsMap, headers);
        // 设置 Authorization
        restTemplate.getInterceptors().add(
                new BasicAuthenticationInterceptor("WebClient","123456"));

        ResponseEntity<OAuth2AccessToken> result;

        try {
            //发送请求,从TokenEndpoint类中可以看到返回值是OAuth2AccessToken
            result = restTemplate.postForEntity(GETTOKENURL, entity, OAuth2AccessToken.class);
        }catch (HttpClientErrorException e){
            return new Result(HttpServletResponse.SC_FORBIDDEN,null,e.getMessage());
        }


        //处理返回结果
        if (result.getStatusCode()!= HttpStatus.OK){
            return new Result(HttpServletResponse.SC_UNAUTHORIZED,null,"登录失败");
        }

        //在这里也可以使用vo对象,封装好前端需要的数据返回,这个token如果以前设置了token加强信息,这里也能获取到
        return new Result(HttpServletResponse.SC_OK,result.getBody(),"登录成功");
    }




    //获取验证码的方法,将验证码存储到redis中
    @GetMapping("/getVerifyCode")
    public Result getVerifyCode() throws IOException {

        //1.生成验证码
        String codeKey = VerifyCodeUtils.generateVerifyCode(4);
        log.info("验证码:" + codeKey);
        //2.存储验证码 redis
        String codeKeyIndex = UUID.randomUUID().toString();
        //stringRedisTemplate.opsForValue().set(codeKey, code, 60, TimeUnit.SECONDS);
        redisOperator.set(REDIS_USER_CODEKEY+":"+codeKeyIndex,codeKey,500);
        //3.base64转换验证码
        ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
        VerifyCodeUtils.outputImage(120, 60, byteArrayOutputStream, codeKey);
        String data = "data:image/png;base64," + Base64Utils.encodeToString(byteArrayOutputStream.toByteArray());
        //4.响应数据
        Map<String, String> map = new HashMap<>();
        map.put("data",data);
        map.put("codeKeyIndex",codeKeyIndex);

        return new Result(200,map,"获取验证码成功");
    }
    //重新登录,刷新令牌的使用,也要使用客户端id和密码进行查找新的令牌。备用
    @GetMapping("/refresh")
    public Result refresh(@RequestParam("refreshToken") String refreshToken){
        //从请求头中解析出refresh_token
        System.out.println(refreshToken);
        // 构建请求头
        HttpHeaders headers = new HttpHeaders();
        headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
        MultiValueMap<String,Object> paramsMap=new LinkedMultiValueMap<>();
        paramsMap.add("grant_type", "refresh_token");
        paramsMap.add("refresh_token",refreshToken);
        //在请求头中带上客户端的账号密码
        HttpEntity<MultiValueMap<String, Object>> entity = new HttpEntity<>(paramsMap, headers);
        //用refresh_token获取到新的access_token
        restTemplate.getInterceptors().add(new BasicAuthenticationInterceptor("WebClient","123456"));
        OAuth2AccessToken token;
        try{
            token = restTemplate.postForObject(GETTOKENURL,entity,OAuth2AccessToken.class);
        }catch (HttpClientErrorException e){
            return new Result(HttpServletResponse.SC_FORBIDDEN,null,e.getMessage());
        }
        return new Result(200,token,"登录成功");
    }
}

五、认证服务器也可以是资源服务器

  1. 编写UserController
    UserController负责获取用户信息以及用户退出,获取用户信息同样需要用户先进行登录,否则获取不了,因为redis仓库已经实现了用户退出的逻辑,因此直接调用即可。访问资源服务器都需要将访问令牌放到请求头中,因为资源服务器需要先到认证服务器处校验用户是否登录,如果已经登录那么直接获取请求头的token就可以实现用户退出了。
@RestController
@RequestMapping("/user")
public class UserController {


    @Autowired
    private RedisTokenStore redisTokenStore;


    //获取用户信息接口
    @RequestMapping("getUserInfo")
    public Result getUserInfo(Authentication authentication){

        return new Result(200,authentication,"获取用户信息成功");

    }


    //用户退出登录接口
    @RequestMapping("/logout")
    public Result logout(@RequestHeader("authorization") String authorization){


        if (!StringUtils.isEmpty(authorization)){

            String access_token = authorization.toLowerCase().replace("bearer ", "");
            //根据访问令牌获取token信息
            OAuth2AccessToken token = redisTokenStore.readAccessToken(access_token);


            if (token!=null){
                //根据token信息删除redis中的数据
                redisTokenStore.removeAccessToken(token);
                OAuth2RefreshToken refreshToken = token.getRefreshToken();
                redisTokenStore.removeRefreshToken(refreshToken);
            }

        }
        return new Result(200,null,"退出成功");

    }

}
  1. 编写资源服务器配置文件
    需要注意如果认证服务器同时也是资源服务器的话,可以发现资源服务器的配置文件也是可以配置放行和认证规则的,如果其中某些资源路径配置和web安全配置类重合,资源服务器的配置文件优先级是会大于web安全配置类的,那么web安全配置类重合的资源路径的放行和认证规则就会被覆盖。
/**
 * 资源服务
 */
@Configuration
@EnableResourceServer
public class ResourceServerConfig extends ResourceServerConfigurerAdapter {

    @Override
    public void configure(HttpSecurity http) throws Exception {
        // 配置放行的资源
        http.authorizeRequests()
                .anyRequest()
                .authenticated()
                .and()
                .requestMatchers()
                //登录后才能进行访问的资源路径
                .antMatchers("/user/**");
    }

}