【Spring Security安全框架 入门及基于微服务单点登录认证】

前言

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 Security框架,其认证简易流程如下:
要完成访问控制,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. 具体认证流程

在这里插入图片描述

  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/**");
    }

}
  • 1
    点赞
  • 8
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值