基于Spring Security + OAuth2 的SSO单点登录(服务端)

相关技术

  • spring security: 用于安全控制的权限框架

  • OAuth2: 用于第三方登录认证授权的协议

  • JWT:客户端和服务端通信的数据载体

传统登录

登录web系统后将用户信息保存在session中,sessionId写入浏览器的cookie中,每次访问系统,浏览器自动携带此cookie,服务端根据此sessionId取到相应的session,若为空则表示登录已失效,不为空则表示用户已登录,不需要用户再次输入用户名密码。

单点登录

单点登录是一种多站点共享登录访问授权机制,访问用户只需要在一个站点登录就可以访问其它站点需要登录访问的资源(url)。用户在任意一个站点注销登录,则其它站点的登录状态也被注销。简而言之就是:一处登录,处处登录。一处注销,处处注销。
spring-security + OAuth2 完美解决了完全跨域的问题。

单点登录时序图
基于Spring Security + OAuth2 的SSO单点登录(服务端)_第1张图片

看完上面的时序图,大家应该明白,使用OAuth2整合的单点登录本质上还是利用cookie + session的方式,虽然客户端和(服务端)认证中心交互采用的是JWT的方式,但浏览器和客户端还是采用cookie+session的方式。

整合SSO认证中心

1. 引入核心依赖

<dependency>
   <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.security.oauth.boot</groupId>
    <artifactId>spring-security-oauth2-autoconfigure</artifactId>
    <version>2.1.3.RELEASE</version>
</dependency>

2. Security的核心配置文件

@Slf4j
@Configuration
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    @Qualifier("securityAuthenticationProvider")
    private AuthenticationProvider securityAuthenticationProvider;

    @Autowired
    @Qualifier("userLoginSuccessHandler")
    private AuthenticationSuccessHandler userLoginSuccessHandler;

    @Autowired
    @Qualifier("securityAuthenticationFailureHandler")
    private AuthenticationFailureHandler securityAuthenticationFailureHandler;

    @Autowired
    @Qualifier("securityLogoutSuccessHandler")
    private LogoutSuccessHandler securityLogoutSuccessHandler;

    @Autowired
    @Qualifier("securityAccessDeniedHandler")
    private AccessDeniedHandler securityAccessDeniedHandler;

    @Autowired
    @Qualifier("securityAuthenticationEntryPoint")
    private AuthenticationEntryPoint securityAuthenticationEntryPoint;

    @Autowired
    @Qualifier("urlFilterInvocationSecurityMetadataSource")
    UrlFilterInvocationSecurityMetadataSource urlFilterInvocationSecurityMetadataSource;

    @Autowired
    @Qualifier("urlAccessDecisionManager")
    AccessDecisionManager urlAccessDecisionManager;

    /**
     * 访问静态资源
     */
    @Override
    public void configure(WebSecurity web) throws Exception {
        web.ignoring().antMatchers(
                "/css/**",
                "/js/**",
                "/images/**",
                "/fonts/**",
                "/favicon.ico",
                "/static/**",
                "/resources/**","/error","/status/*", "/swagger-ui.html", "/v2/**", "/webjars/**", "/swagger-resources/**");
    }

    @Override
    protected void configure(AuthenticationManagerBuilder auth) {
        auth.authenticationProvider(securityAuthenticationProvider);
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {

        http
            .authorizeRequests()
                .anyRequest()
                .authenticated()
                .withObjectPostProcessor(urlObjectPostProcessor())
            .and()
                .formLogin()
                .loginPage("/login") //自定义登录页面
                .loginProcessingUrl("/login")
                .usernameParameter("username")
                .passwordParameter("password")
                .permitAll()
                .failureHandler(securityAuthenticationFailureHandler)
                .successHandler(userLoginSuccessHandler)
            .and()
                .exceptionHandling()
                .authenticationEntryPoint(securityAuthenticationEntryPoint)
                .accessDeniedHandler(securityAccessDeniedHandler)
            .and()
                .logout()
                .deleteCookies("SESSION")
                .logoutUrl("/logout")
                .logoutSuccessHandler(securityLogoutSuccessHandler)
                .permitAll()
            .and()
            	// 关闭csrf,还可开放退出的GET请求方式,否则只有POST请求方式
                .csrf().disable();

        http
                .sessionManagement()
                // 无效session跳转
                .invalidSessionUrl("/login")
                .maximumSessions(1)
                // session过期跳转
                .expiredUrl("/login")
                .sessionRegistry(sessionRegistry());
    }

    /**
     * 解决session失效后 sessionRegistry中session没有同步失效的问题
     * @return
     */
    @Bean
    public HttpSessionEventPublisher httpSessionEventPublisher() {
        return new HttpSessionEventPublisher();
    }

    @Bean
    public SessionRegistry sessionRegistry() {
        return new SessionRegistryImpl();
    }

    public ObjectPostProcessor urlObjectPostProcessor() {
        return new ObjectPostProcessor<FilterSecurityInterceptor>() {
            @Override
            public <O extends FilterSecurityInterceptor> O postProcess(O o) {
                o.setSecurityMetadataSource(urlFilterInvocationSecurityMetadataSource);
                o.setAccessDecisionManager(urlAccessDecisionManager);
                return o;
            }
        };
    }

    @Bean
    @Override
    public AuthenticationManager authenticationManagerBean() throws Exception {
       return super.authenticationManagerBean();
    }


    /**
     * 设置加密方式
     * @return
     */
    @Bean
    public PasswordEncoder passwordEncoder(){
        return new BCryptPasswordEncoder();
    }
}

3. 配置基于OAuth2 的授权服务

/**
 * @author lirong
 * Date 2019-3-18 09:04:36
 */
@Configuration
public class OAuth2ServerConfig {

    /**
     * 注册资源服务器,开放给SSO客户端访问的资源
     */
    @Configuration
    @EnableResourceServer
    protected static class ResourceServerConfiguration extends ResourceServerConfigurerAdapter {
        private static final String RESOURCE_ID = "libii-sso-server";
        @Override
        public void configure(ResourceServerSecurityConfigurer resources) {
            // 如果关闭 stateless,则 accessToken 使用时的 session id 会被记录,后续请求不携带 accessToken 也可以正常响应
            resources.resourceId(RESOURCE_ID).stateless(false);
        }

        /**
         * 为oauth2单独创建角色,这些角色只具有访问受限资源的权限,可解决token失效的问题
         * @param http
         * @throws Exception
         */
        @Override
        public void configure(HttpSecurity http) throws Exception {
            http
                    // 获取登录用户的 session
                    .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)
                    .and()
                    // 资源服务器拦截的路径 注意此路径不要拦截主过滤器放行的URL
                    .requestMatchers().antMatchers("/user/**");
            http
                    .authorizeRequests()
                    // 配置资源服务器已拦截的路径才有效
                    .antMatchers("/user/**").authenticated();
            // .access(" #oauth2.hasScope('select') or hasAnyRole('ROLE_超级管理员', 'ROLE_设备管理员')");

            http
                    .exceptionHandling().accessDeniedHandler(new OAuth2AccessDeniedHandler())
                    .and()
                    .authorizeRequests()
                    .anyRequest()
                    .authenticated();
        }
    }

    @Configuration
    @EnableAuthorizationServer
    protected static class AuthorizationServerConfiguration extends AuthorizationServerConfigurerAdapter {

        @Autowired
        AuthenticationManager authenticationManager;
        @Autowired
        private DataSource dataSource;
        @Autowired
        SecurityUserService userDetailsService;

        // @Bean
        // public TokenStore tokenStore() {
        //     return new JdbcTokenStore(dataSource);
        // }

        @Bean
        public TokenStore jwtTokenStore() {
            return new JwtTokenStore(jwtAccessTokenConverter());
        }

        @Bean
        public JwtAccessTokenConverter jwtAccessTokenConverter(){
            JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
            converter.setSigningKey("libii-sso-server");
            return converter;
        }

        /**
         * 密码加密
         */
        @Bean
        public PasswordEncoder passwordEncoder() {
            return new BCryptPasswordEncoder();
        }

        @Bean
        public AuthorizationCodeServices authorizationCodeServices(DataSource dataSource) {
            return new JdbcAuthorizationCodeServices(dataSource);
        }


        @Override
        public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
            // 1. 数据库的方式
            clients.jdbc(dataSource);

            // 2. 内存的方式
            // 定义了两个客户端应用的通行证
            // clients.inMemory()
            //         .withClient("moregame")
            //         .secret(new BCryptPasswordEncoder().encode("123456"))
            //         .authorizedGrantTypes("authorization_code", "refresh_token")
            //         .redirectUris("http://www.site2.com/login")
            //         .scopes("all")
            //         .accessTokenValiditySeconds(3600)
            //         .autoApprove(true)
            //
            //         .and()
            //         .withClient("sheep1")
            //         .secret(new BCryptPasswordEncoder().encode("123456"))
            //         .authorizedGrantTypes("authorization_code", "refresh_token")
            //         .redirectUris("http://www.site1.com/login")
            //         .scopes("all")
            //         .accessTokenValiditySeconds(3600)
            //         .autoApprove(true);
        }

        /**
         * 声明授权和token的端点以及token的服务的一些配置信息,
         * 比如采用什么存储方式、token的有效期等
         * @param endpoints
         */
        @Override
        public void configure(AuthorizationServerEndpointsConfigurer endpoints) {

            endpoints
                    .tokenStore(jwtTokenStore())
                    .accessTokenConverter(jwtAccessTokenConverter())
                    .authenticationManager(authenticationManager)
                    .userDetailsService(userDetailsService);
        }

        /**
         * 声明安全约束,哪些允许访问,哪些不允许访问
         * @param security
         */
        @Override
        public void configure(AuthorizationServerSecurityConfigurer security) {
            //允许表单认证
            security.allowFormAuthenticationForClients();
            security.passwordEncoder(passwordEncoder());
            // 对于CheckEndpoint控制器[框架自带的校验]的/oauth/check端点允许所有客户端发送器请求而不会被Spring-security拦截
            security.tokenKeyAccess("isAuthenticated()");
        }
    }
}

配置说明:

1. 此处把授权服务器和资源服务器一起配置了,可分开配置,如若不配置资源服务器,则在客户端无法通过获取的accessToken和服务端交互(登录和登出除外,@EnableOAuth2Sso注解做了自动处理),当然如果你的项目登录、登出够用了,也可不配置。
2. 本文采用的是数据库的方式存储OAuth的客户端信息,内存的方式也在文中提供了,采用JWT的方式实现TokenStore,熟悉OAuth2 的朋友应该知道,有4种方式来配置tokenStore,详情请移步这里tokenStore详解, 虽然我们也使用数据库来存储客户端的配置信息,但是因为使用的jwtTokenStore,access_token等信息都蕴藏在JWT里面,所以数据库只需要一张oauth_client_details表就行了,不需要存储授权码和access_token等信息。
3. 引入userDetailsService,是为了将OAuth2签发的access_token和系统的用户绑定起来,这样,此token就具有了系统用户所具有的访问权限。(有人说,这样绑定后,只要系统用户的session没有失效,token就会自动刷新,不会过期,经测试,token还是会过期,但应该还是有某种其他的联系,有兴趣的小伙伴可以深度研究)

4. 单点退出

原理: 用户在某个客户端执行退出操作后,通知认证中心和其他客户端,使他们也触发用户退出的相应操作,这就是单点退出。
遇到的问题: @EnableOAuth2Sso只帮我们实现了通知认证中心退出的操作,此时浏览器和各个客户端建立的session依旧有效,那怎样才能通知客户端呢?。
解决方案: 熟悉spring-security的朋友都知道,security鉴权时会拦截每一个访问的URL,判断用户是否登录,以及用户是否有此URL访问的权限,基于此,我们可在此过程中插入对用户是否在其他客户端退出的判断。在用户登录时,先到认证中心授权认证,登录成功的同时,认证中心将用户具有的权限信息(以用户名为key,权限信息作为value)存入到客户端能够访问的redis中,用户退出时,由认证中心去删除redis该用户的权限信息即可。用户访问客户端期间,一旦客户端的security取不到redis中用户的权限信息了,即表示用户已在其他客户端退出了。
详情可参考我的 单点登录客户端 这篇博客。

测试

单点登录:
基于Spring Security + OAuth2 的SSO单点登录(服务端)_第2张图片
单点退出:

欢迎大家留言讨论
Github源码:单点登录服务端 、单点登录客户端

你可能感兴趣的:(Spring,Security,SSO,Java,spring-security)