Spring Security实现OAuth2.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 如何确保第三方客户端只能拿到授权用户的信息?

待研究

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 216,039评论 6 498
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,223评论 3 392
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 161,916评论 0 351
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,009评论 1 291
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,030评论 6 388
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,011评论 1 295
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,934评论 3 416
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,754评论 0 271
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,202评论 1 309
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,433评论 2 331
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,590评论 1 346
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,321评论 5 342
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,917评论 3 325
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,568评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,738评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,583评论 2 368
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,482评论 2 352

推荐阅读更多精彩内容