springsecurity使用redis实现单点登录

文章目录


前言

本文采用springsecurity oauth2 + redis实现单点登录,现在如果想要使用springsecurity实现单点登录的话,比较流行的方法是使用jwt方式来实现,虽然jwt优点很多,本身就能携带很多信息,但它是无状态的,服务端不用保存它的信息,这样就有一个问题,一旦jwt的token发送到用户手中,那么只要token不过期,用户就可以一直访问系统,也就没有退出功能了,如果要实现退出功能,自然就要在服务端存储jwt的信息,就违背了jwt的思想了,redis实现单点登录则不存在这个问题,用户登录成功后用户信息会被存储到redis中,可以实现正常的退出。


一、oauth2认证的4种模式的选择

在编写认证服务器之前,首先要确定使用哪一种oauth2的认证方式,它们分别是授权码模式,密码模式,简化模式和客户端模式,这里我采用的是密码模式来实现单点登录,理由是密码模式相对于授权码模式来说不需要获取授权码即可获取访问令牌,并且认证服务器和资源服务器都是自己开发的项目,这样是很适合使用密码模式的,授权码模式安全级别较高,也可以使用。

二、认证服务器的编写

认证服务器的编写是最重要的部分,它负责用户的认证和授权,资源服务器可以有多个,而认证服务器只有一个。
编写完成后的架构
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()");
    }

}

到了这里,其实认证服务器就已经配置好了,为了测试它的功能,就可以创建controller类进行测试了。

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

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

server.port=9050
        
#配置单节点的redis服务
spring.redis.host=127.0.0.1
spring.redis.database=0
spring.redis.port=6379
  1. 创建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,"登录成功");
    }

}

为了真实,还添加了验证码的功能,登录流程是首先访问getVerifyCode获取到验证码和验证码在redis中的key,可以在浏览器或者postman中访问,获取到验证码base64图片上面就是验证码的信息,也可以到redis或者控制台中查看。
springsecurity使用redis实现单点登录
第二步,使用postman进行登录,带上账号密码以及验证码和验证码的key一起访问login方法,账号密码是yuki和123456
springsecurity使用redis实现单点登录
这样就登录成功了,获取到最重要的访问令牌access_token了,它的过期时间是600秒,正常可以设置得长一点。刷新令牌也要保存下来,防止访问令牌过期,如果不保存,下次就要再次登录了,有了刷新令牌,就可以实现记住我的功能了。

如果访问令牌过期了,就访问refresh方法再次获取访问令牌
springsecurity使用redis实现单点登录
这样LoginController的方法就测试完成了,接下来就要实现用户退出的功能了,用户首先要登录后才能退出,因此认证服务器也可以被当作是一个资源服务器,用来校验用户是否登录,只有登录了才能退出。

三.认证服务器也可以是资源服务器

这里的代码依然在认证服务器中编写

  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. 编写资源服务器配置文件
/**
 * 资源服务
 */
@Configuration
@EnableResourceServer
public class ResourceServerConfig extends ResourceServerConfigurerAdapter {

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

}
  1. 测试退出
    redis一开始的数据
    springsecurity使用redis实现单点登录
    springsecurity使用redis实现单点登录
    退出成功后就会发现redis中的这个用户的数据都被删除了,这样认证服务器的功能就编写完成了,接下来就到编写资源服务器了,这里编写两个资源服务器,分别是学生和老师的资源服务器。

四. 编写学生资源服务器

在创建两个子项目,分别为student-client和teacher-client,它们添加的代码和刚才认证服务器添加的代码可以是一样的,但还可以再添加多两个类,分别为AccessDeniedHandler权限不足异常类以及AccessDeniedHandler认证失败处理类,不添加也可以,下面以学生资源服务器为例子。

编写完成后的架构
springsecurity使用redis实现单点登录

  1. 编写AccessDeniedHandler权限不足异常类
@Component//自定义权限不足异常类
public class CustomAccessDeniedHandler implements AccessDeniedHandler {

    @Resource
    private ObjectMapper objectMapper;

    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException authException) throws IOException, ServletException {
        // 返回 JSON
        response.setContentType("application/json;charset=utf-8");
        // 状态码 403
        response.setStatus(HttpServletResponse.SC_FORBIDDEN);
        // 写出
        PrintWriter out = response.getWriter();
        String errorMessage = authException.getMessage();
        if (StringUtils.isBlank(errorMessage)) {
            errorMessage = "权限不足!";
        }

        Result result = new Result(HttpServletResponse.SC_FORBIDDEN, "权限不足,无法访问资源", errorMessage);

        out.write(objectMapper.writeValueAsString(result));
        out.flush();
        out.close();
    }
}
  1. 编写AccessDeniedHandler认证失败处理类
/**
 * 认证失败处理
 */
@Component
public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint {

    @Resource
    private ObjectMapper objectMapper;

    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response,
                         AuthenticationException authException) throws IOException {
        // 返回 JSON
        response.setContentType("application/json;charset=utf-8");
        // 状态码 401
        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
        // 写出
        PrintWriter out = response.getWriter();
        String errorMessage = authException.getMessage();
        if (StringUtils.isBlank(errorMessage)) {
            errorMessage = "登录失效!";
        }

        Result result = new Result(HttpServletResponse.SC_UNAUTHORIZED, "token无效", errorMessage);

        out.write(objectMapper.writeValueAsString(result));
        out.flush();
        out.close();
    }

}
  1. 编写资源服务器配置文件
    刚才认证服务器我们不需要配置ResourceServerTokenServices是因为使用了默认的DefaultTokenServices,这里就不能用默认的了。
@Configuration
@EnableResourceServer // 标识为资源服务器,请求服务中的资源,就要带着token过来,找不到token或token是无效访问不了资源
@EnableGlobalMethodSecurity(prePostEnabled = true) // 开启方法级别权限控制
public class ResourceConfig extends ResourceServerConfigurerAdapter {

    @Autowired
    private CustomAuthenticationEntryPoint myAuthenticationEntryPoint;

    @Autowired
    private CustomAccessDeniedHandler customAccessDeniedHandler;

    @Override
    public void configure(ResourceServerSecurityConfigurer resources) throws Exception {
        //因为采取redis存储token,因此要到授权服务器中验证token信息
        resources.tokenServices(tokenService())
        //当用户传入无效的token会触发myAuthenticationEntryPoint的commence方法进行处理
                .authenticationEntryPoint(myAuthenticationEntryPoint)
                //当用户的权限不足时,customAccessDeniedHandler的handle方法进行处理
                .accessDeniedHandler(customAccessDeniedHandler);
    }


    @Override
    public void configure(HttpSecurity http) throws Exception {
        http
                .authorizeRequests(request -> {
                    request
                            .antMatchers("/student/**").hasRole("STUDENT")
                            .anyRequest().permitAll(); })
                //关闭csrf选项
                .csrf().disable()
                //基于token验证,关闭session
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);


    }



    /**
       * 配置资源服务器如何验证token有效性
       * 1. DefaultTokenServices
       *  如果认证服务器和资源服务器同一服务时,则直接采用此默认服务验证即可
       * 2. RemoteTokenServices (当前采用这个)
       *  当认证服务器和资源服务器不是同一服务时, 要使用此服务去远程认证服务器验证
       */
        @Bean
        public ResourceServerTokenServices tokenService() {
            // 资源服务器去远程认证服务器验证 token 是否有效
            RemoteTokenServices service = new RemoteTokenServices();
            // 请求认证服务器验证URL,注意:默认这个端点是拒绝访问的,要设置认证后可访问
            service.setCheckTokenEndpointUrl("http://localhost:9050/oauth/check_token");
            // 在认证服务器配置的客户端id
            service.setClientId("WebClient");
            // 在认证服务器配置的客户端密码
            service.setClientSecret("123456");
            return service;
        }


}
  1. 编写StudentController
@RestController
@RequestMapping("/student")
public class StudentController {

    @RequestMapping("/hello")
    public String auth(){
        return "Hello World!";
    }

    @RequestMapping("/auth")
    public Object auth(Authentication authentication){
        return authentication;
    }

    @RequestMapping("/auth2")
    public Object auth2(Authentication authentication){
        OAuth2Authentication auth2Authentication = (OAuth2Authentication)authentication;
        Authentication userAuthentication = auth2Authentication.getUserAuthentication();
        return userAuthentication;
    }


    @RequestMapping("/auth3")
    public Object auth3(Authentication authentication){
        Object principal = authentication.getPrincipal();
        return principal;
    }

}
  1. 启动项目进行测试
    先测试不携带token直接访问StudentController的方法
    springsecurity使用redis实现单点登录
    token无效,输入错误的token也是这样。接下来带上正确的token访问方法,因为此时yuki用户的角色是TEACHER,因此它无法访问STUDENT的方法
    springsecurity使用redis实现单点登录

五.编写老师资源服务器

代码都是一样的,只是控制器和资源服务配置稍微不一样而已

//资源服务配置改变
http
        .authorizeRequests(request -> {
            request
                    .antMatchers("/teacher/**").hasRole("TEACHER")
                    .anyRequest().permitAll(); })

TeacherController

@RestController
@RequestMapping("/teacher")
public class TeacherController {

    @RequestMapping("/hello")
    public String auth(){
        return "Hello World!";
    }

//    @PreAuthorize("hasAnyRole('TEACHER')")
    @RequestMapping("/auth")
    public Object auth(Authentication authentication){
        return authentication;
    }

}

因为此时用户的角色是TEACHER,因此可以访问TeacherController中的方法,启动项目进行测试
springsecurity使用redis实现单点登录
这样只需要登录一次,获取到redis的token,接下来一直携带这个token就可以访问任意的资源服务器了,从而实现了单点登录的功能。

总结

这就是用springsecurity oauth2+redis实现的单点登录了,微服务架构只要理解思想也是差不多的方法,我会把项目放到百度云,要启动项目只需要开启redis服务,然后修改一下认证服务器的配置文件中的redis配置即可,有需要的来试一下吧。
链接:https://pan.baidu.com/s/1od-WjsrL_BTSJvaDhNpuXg
提取码:leon

上一篇:一个字,绝!不愧是阿里顶配的保姆级SpringSecurity笔记!


下一篇:想进BTAJ?springsecurity前后端分离