美文网首页Oauth权限
Spring Security实现OAuth2.0——授权服务

Spring Security实现OAuth2.0——授权服务

作者: 文景大大 | 来源:发表于2021-08-09 20:14 被阅读0次

    一、OAuth2.0简介

    关于OAuth2.0的介绍,网上有很多说明的文章了,这里就不做展开详细讲解,只是把必要的示意图贴上,再简单说明,方便后面复习。

    如下是官方给出的认证过程示意图:

    • Client,指发起认证流程的一方,比如某个APP、Web站点;
    • Resource Owner,指在Resource Server上拥有资源的一方,需要访问Client,并允许Client从Resource Server获取到自己的信息;
    • Authorization Server,为了保护Resource Owner在Resource Server上的资源,对Client进行认证和授权的服务;
    • Resource Server,存放Resource Owner的资源,为Client提供获取Resource Owner的资源的服务;
    1.PNG

    我们再来举一个详细点的例子:

    • Client,就是“黑马程序员”这个网站;
    • Resource Owner,就是“用户”,想要利用自己在微信上的注册信息在“黑马程序员”这个网站实现注册登录;
    • Authorization Server,就是“微信认证”,得到用户授权的情况下,把合法凭证令牌给到“黑马程序员”这个网站;
    • Resource Server,就是“微信用户信息”这个服务,用户在其上拥有一些注册信息,根据合法的凭证令牌将信息给到“黑马程序员”这个网站;
    OAuth2.0认证授权过程示意图

    二、准备工作

    本案例中总共涉及四个角色,其中用户是自然人,不需要准备;其它三个角色都是程序代码,需要做一些准备工作。

    我们创建一个父工程:security-oauth,主要的依赖有:

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>
    <dependencyManagement>
        <dependencies>
            <dependency>
                <groupId>org.springframework.cloud</groupId>
                <artifactId>spring-cloud-dependencies</artifactId>
                <version>2020.0.3</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
        </dependencies>
    </dependencyManagement>
    

    然后,我们依次创建三个子模块:

    • auth-authorize,表示我们的授权服务,8081端口;
      依赖信息:
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-oauth2</artifactId>
            <version>2.2.5.RELEASE</version>
        </dependency>
    
    • auth-resource,表示我们的资源服务,8082端口;
      依赖信息:
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-oauth2</artifactId>
            <version>2.2.5.RELEASE</version>
        </dependency>
    
    • auth-client,表示我们的客户端,8080端口;
      依赖信息:
            <dependency>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-starter-thymeleaf</artifactId>
            </dependency>
    

    本篇文章主要讲解授权服务的实现,关于资源服务和客户端的示例在后面的篇文章中演示。

    三、授权码模式

    通过第一节的示意图我们知道,授权服务的主要作用就是对用户进行认证(用户密码登录),然后将用户的合法性(授权码、访问令牌)传递给客户端。

    所以我们需要一个提供给用户的登录功能,还需要保留用户的账号密码,对用户进行认证,这个可以使用WebSecurityConfigurerAdapter进行,这在原先讲解Spring Security的时候就说到了,如果不熟悉可以翻看原来的文章,此处不赘述。

    @EnableWebSecurity
    public class MySecurityConfig extends WebSecurityConfigurerAdapter {
    
        @Autowired
        private MyUserDetailsService userDetailsService;
    
        /**
         * 对请求进行鉴权的配置
         * @param http
         * @throws Exception
         */
        @Override
        protected void configure(HttpSecurity http) throws Exception {
            http.authorizeRequests()
                    .anyRequest().authenticated()
                    .and()
                    // 没有权限进入内置的登录页面
                    .formLogin()
                    .and()
                    // 暂时关闭CSRF校验,允许get请求登出
                    .csrf().disable();
        }
    
        @Override
        protected void configure(AuthenticationManagerBuilder auth) throws Exception {
            // 使用userDetailsService进行认证
            auth.userDetailsService(userDetailsService);
        }
    
        /**
         * 密码加密器,供在UserDetailsService中验证密码时使用
         * @return
         */
        @Bean
        public PasswordEncoder passwordEncoder() {
            return new BCryptPasswordEncoder();
        }
    
    }
    

    相应的,我们需要一个UserDetailsService来提供用户信息。

    @Service
    public class MyUserDetailsService implements UserDetailsService {
        @Autowired
        private PasswordEncoder passwordEncoder;
    
        @Override
        public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
            // 为了演示方便,使用内存定义用户的真实账密及其访问权限
            return User
                    .withUsername("zhangxun")
                    .password(passwordEncoder.encode("mm123"))
                    // 设置当前用户可以拥有的权限信息,授权码模式下,用户输入账密后就拥有该权限
                    .authorities("user:query")
                    .build();
        }
    }
    

    到此,我们的用户就可以使用账密登录授权服务了,但是此时还没有实现任何一点授权服务的功能,所以见下面。

    我们先定义token令牌的管理策略,可以选择:

    • 内存管理,默认管理策略,即令牌被创建后是保存在单机内存中的,因此适合授权服务是单机并发量不大的场景下;
    • JDBC管理,令牌被托管到数据库进行管理,适用于授权服务是集群的场景,不同机器之间可以通过数据库来共享token
    • JWT管理,授权服务不需要存储任何token,只需要对访问令牌进行计算即可验证token的合法性,也比较适合授权服务是集群的场景,而且是现在比较主流的使用方案;

    本案例先使用内存管理token,其它方式在后面会介绍到。

    @Configuration
    public class TokenConfig {
        @Bean
        public TokenStore tokenStore(){
            // 使用内存管理token策略
            return new InMemoryTokenStore();
        }
    
    }
    

    然后,就是我们的授权服务核心配置类了:

    @Configuration
    // 标记授权服务
    @EnableAuthorizationServer
    public class MyAuthorizationServerConfig extends AuthorizationServerConfigurerAdapter {
    
        // 授权码服务
        @Autowired
        private AuthorizationCodeServices authorizationCodeServices;
        // 访问令牌服务
        @Autowired
        private AuthorizationServerTokenServices tokenServices;
        // 访问令牌管理服务
        @Autowired
        private TokenStore tokenStore;
        // 客户端服务,由于我们使用了内存模式,会自动创建一个默认的客户端服务
        @Autowired
        private ClientDetailsService clientDetailsService;
    
        /**
         * 配置客户端的详情,提供客户端的信息
         *
         * 客户端通过访问如下地址来获取授权码
         * /oauth/authorize?client_id=iSchool&response_type=code&scope=all&redirect_uri=http://localhost:8080
         * 客户端通过访问如下地址来获取访问token,访问token仅能使用一次
         * /oauth/token?client_id=iSchool&client_secret=mysecret&grant_type=authorization_code&code=授权码&redirect_uri=http://localhost:8080
         *
         * @param clients
         * @throws Exception
         */
        @Override
        public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
            clients
                    // 基于内存方式存储客户信息
                    .inMemory()
                    // client_id,分配给客户端的标识
                    .withClient("iSchool")
                    // secret密钥,加密存储
                    .secret(new BCryptPasswordEncoder().encode("mysecret"))
                    // 当前仅开启授权码模式,refresh_token表示开启刷新令牌
                    .authorizedGrantTypes("authorization_code","refresh_token")
                    // 允许授权的范围,默认为空表示允许访问全部范围,这个在资源服务器那里用的到
                    .scopes("all")
                    // 资源服务器的ID配置,可以是多个,这个在资源服务器那里用的到
                    .resourceIds("user")
                    // 设置该client_id的主体所拥有的权限信息,在客户端模式下生效,在资源服务器那里用的到
                    .authorities("user:query")
                    // 需要用户手动授权,即会弹出界面需要用户手动点击授权
                    .autoApprove(false)
                    // 重定向地址,这里是第三方客户端的地址,用来接收授权服务器返回的授权码
                    .redirectUris("http://localhost:8080");
                    // 可以通过and()再添加其它的客户端信息,这里省略
        }
    
        /**
         * 配置令牌的访问端点和令牌管理服务
         * 默认的访问端点如下:
         * /oauth/authorize:授权端点,获取授权码
         * /oauth/token:令牌端点,获取访问令牌
         * /oauth/confirm_access:用户确认授权提交端点
         * /oauth/error:授权服务错误信息端点
         * /oauth/check_token:提供给资源服务访问的令牌验证端点
         * /oauth/token_key:提供公有密匙的端点,JWT模式使用
         *
         * @param endpoints
         * @throws Exception
         */
        @Override
        public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
            endpoints
                    // 指定授权码管理策略
                    .authorizationCodeServices(authorizationCodeServices)
                    // 指定token管理策略,token会自己生成一个随机值
                    .tokenServices(tokenServices)
                    // 指定访问token的请求方法,实际应该使用POST方式,这里为了演示方便使用GET
                    .allowedTokenEndpointRequestMethods(HttpMethod.GET);
        }
    
        /**
         * 配置令牌访问端点的安全约束
         *
         * @param security
         * @throws Exception
         */
        @Override
        public void configure(AuthorizationServerSecurityConfigurer security) throws Exception {
            security
                    // 放开/oauth/check_token这个端点,供资源服务器调用来校验访问token的合法性
                    .checkTokenAccess("permitAll()")
                    // 开启表单认证
                    .allowFormAuthenticationForClients();
        }
    
        /**
         * 配置授权码模式下授权码的存取方式,此时采用内存模式
         * @return
         */
        @Bean
        public AuthorizationCodeServices authorizationCodeServices() {
            return new InMemoryAuthorizationCodeServices();
        }
    
        /**
         * 配置令牌管理服务
         * @return
         */
        @Bean
        public AuthorizationServerTokenServices tokenServices() {
            DefaultTokenServices services = new DefaultTokenServices();
            // 配置客户端详情服务,获取客户端的信息
            services.setClientDetailsService(clientDetailsService);
            // 支持刷新令牌
            services.setSupportRefreshToken(true);
            // 配置令牌的存储方式,此时采用内存方式存储
            services.setTokenStore(tokenStore);
            // 访问令牌有效时间2小时
            services.setAccessTokenValiditySeconds(7200);
            // 刷新令牌的有效时间3天
            services.setRefreshTokenValiditySeconds(259200);
            return services;
        }
    
    }
    

    具体的说明在如上代码中都已经注释说明了,到此,我们的授权码模式就算完成了。启动项目后,我们使用浏览器模拟第三方客户端发起授权请求:

    http://localhost:8081/oauth/authorize?client_id=iSchool&response_type=code&scope=all&redirect_uri=http://localhost:8080
    

    这个请求中包含的内容主要有:

    • /oauth/authorize,这是访问端点,授权服务器对外暴露的,用于给第三方客户端生成授权码的接口;
    • client_id,就是授权服务器分配给第三方客户端的标识,这里随便写一个iSchool,只要授权服务器上有这个客户信息即可;
    • response_type,值code表示需要获取授权码;
    • scope,值为all表示需要申请all这个域的资源访问权限,必须和上面配置中的一致;
    • redirect_uri,即第三方客户端的回调地址,用来获取授权服务器返回的授权码;

    请求发起后,页面就会进入登录页面,要求输入账密进行登录,此处即MyUserDetailsService中写死的zhangxun/mm123,登录成功后,就会跳转到授权页面,

    授权页面

    需要注意到,授权页面有很多信息:

    • 授权给谁?这里是iSchool这个client_id;
    • 授权的范围?是all这个域的资源;

    登录页面和授权页面都是可以定制的,这里为了简单演示,不做过度展开。

    当我们授权成功后,授权服务器就重定向到第三方客户端的地址,并带过来一个授权码:

    http://localhost:8080/?code=y4CwNB
    

    第三方客户端拿到这个授权码之后,就将其传递给自己的后端服务器,由后端服务器再去调用授权服务器换取访问token。

    这里并不是说一定要由后端服务器去获取token,而是token是一种需要保护的令牌,我们当然可以通过前端直接去获取token,但这会导致token被泄露在前端,而且还有第三方客户端的密钥,这些都是需要保密的内容。这里为了方便演示,就直接通过浏览器,使用前端调用授权服务器获取token:

    http://localhost:8081/oauth/token?client_id=iSchool&client_secret=mysecret&grant_type=authorization_code&code=y4CwNB&redirect_uri=http://localhost:8080
    

    然后会得到返回信息:

    {"access_token":"1a6d94be-1f38-4140-bf2e-35b226a7346f","token_type":"bearer","refresh_token":"b41bfe84-717b-4bbe-9e38-2e30073fea29","expires_in":43199,"scope":"all"}
    

    到此,我们就拿到了访问token。

    四、简化模式

    简化模式就是对授权码模式进行了简化,即第三方客户端访问授权服务器时不需要先获取授权码再获取访问token了,而是直接一步到位获取访问token。

    首先,我们需要在授权服务器端的授权配置中开启简化模式:

    // 支持的授权模式,refresh_token表示开启刷新令牌
    .authorizedGrantTypes("implicit","refresh_token")
    

    然后启动授权服务器即可,我们模拟第三方客户端对授权服务器发起请求如下,注意response_type改为了token:

    http://localhost:8081/oauth/authorize?client_id=iSchool&response_type=token&scope=all&redirect_uri=http://localhost:8080
    

    经过登录和授权之后,授权服务器就会重定向到第三方客户端的地址,并带回来访问token:

    http://localhost:8080/#access_token=faa7813f-c9b2-4100-a11b-7d81d18af1f7&token_type=bearer&expires_in=43199
    

    这样,第三方客户端就拿到了访问token,确实简化了不少,甚至都不用密钥,但是缺点也很明显,访问token在前端有泄露的风险,主要用于那些没有后端服务的第三方单页面应用,不是很推荐。

    五、密码模式

    密码模式是在授权码模式的基础上,将用户的账号密码给到第三方客户端,由第三方客户端带着用户的账密,以及它自己的标识和密钥来访问授权服务器,直接获取访问token,由此可以不用用户在授权服务器上进行登录和授权操作。

    首先,我们需要开启密码模式:

    .authorizedGrantTypes("password","refresh_token")
    

    其次,为了支持第三方客户端可以将用户的账密带过来给到授权服务器,我们还需要在如上的MySecurityConfig类中增加认证管理器:

    /**
         * 认证管理器,供密码模式下认证用户时使用
         * @return
         * @throws Exception
         */
    @Bean
    @Override
    public AuthenticationManager authenticationManager() throws Exception {
        return super.authenticationManager();
    }
    

    然后在我们的授权服务配置类MyAuthorizationServerConfig中使用这个认证管理器:

    @Autowired
    private AuthenticationManager authenticationManager;
    
    @Override
    public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
        endpoints
            // 指定认证管理器,在WebSecurityConfigurerAdapter的实现类中注入,密码模式需要用到
            .authenticationManager(authenticationManager)
    }
    

    好了,现在启动授权服务后,模拟第三方客户端的后端服务对授权服务器发起请求如下:

    http://localhost:8081/oauth/token?client_id=iSchool&client_secret=mysecret&grant_type=password&username=zhangxun&password=mm123
    

    得到的返回内容为:

    {"access_token":"325d0c02-89d5-4361-9930-bc91fa9255b0","token_type":"bearer","refresh_token":"b75bfb72-102f-4038-939d-b14b343eda0c","expires_in":43199,"scope":"all"}
    

    这样,第三方客户端就拿到了访问token,但是,需要用户将自己在授权服务器上的账密泄露给第三方客户端,这对于很多授权服务方来说是不可忍受的,除非第三方客户端就是自己方的应用。

    六、客户端模式

    客户端模式也比较简单,只需要第三方客户端给出自己的标识和密钥,授权服务就返回给它访问token,甚至都不用用户的授权行为。

    首先,我们需要开启客户端模式:

    .authorizedGrantTypes("client_credentials","refresh_token")
    

    然后可以将上述密码模式添加的认证管理器予以删除,重启授权服务器即可。

    模拟第三方客户端的后端服务对授权服务器发起请求如下:

    http://localhost:8081/oauth/token?client_id=iSchool&client_secret=mysecret&grant_type=client_credentials
    

    得到的返回内容如下:

    {"access_token":"885bd2f8-9ada-41b9-ac61-2c9a74a8b805","token_type":"bearer","expires_in":43199,"scope":"all"}
    

    这样,第三方客户端就拿到了访问token,但是,这中间根本没有让用户进行授权,不能确保第三方客户端是否会对客户的信息用作非法用途,因此,只有第三方客户端是完全授信的情况下才能使用。

    七、总结

    综上四种模式中,授权码模式是最复杂,但是最安全的,也是现在业内最流行使用的方式;简化模式会导致访问token泄露到前端,安全性得不到保证;密码模式和客户端模式要求第三方客户端是受控制的,能得到完全信任的情况。

    八、思考

    7.1 授权码的必要性是什么?直接返回访问token不行吗?

    不行。

    • 授权码是为了将浏览器地址重定向到第三方客户端的网址,同时告知一个授权码;
    • 授权码即使泄露,没有第三方客户端的密钥也是无法获取访问token的;
    • 访问token是需要保护的令牌,不能在前端出现;

    7.2 如何确保第三方客户端只能拿到授权用户的信息?

    待研究

    相关文章

      网友评论

        本文标题:Spring Security实现OAuth2.0——授权服务

        本文链接:https://www.haomeiwen.com/subject/hhqtbltx.html