Spring Security實現(xiàn)OAuth2.0——授權(quán)服務(wù)

一、OAuth2.0簡介

關(guān)于OAuth2.0的介紹,網(wǎng)上有很多說明的文章了,這里就不做展開詳細(xì)講解,只是把必要的示意圖貼上,再簡單說明,方便后面復(fù)習(xí)。

如下是官方給出的認(rèn)證過程示意圖:

  • Client,指發(fā)起認(rèn)證流程的一方,比如某個APP、Web站點;
  • Resource Owner,指在Resource Server上擁有資源的一方,需要訪問Client,并允許Client從Resource Server獲取到自己的信息;
  • Authorization Server,為了保護Resource Owner在Resource Server上的資源,對Client進行認(rèn)證和授權(quán)的服務(wù);
  • Resource Server,存放Resource Owner的資源,為Client提供獲取Resource Owner的資源的服務(wù);
1.PNG

我們再來舉一個詳細(xì)點的例子:

  • Client,就是“黑馬程序員”這個網(wǎng)站;
  • Resource Owner,就是“用戶”,想要利用自己在微信上的注冊信息在“黑馬程序員”這個網(wǎng)站實現(xiàn)注冊登錄;
  • Authorization Server,就是“微信認(rèn)證”,得到用戶授權(quán)的情況下,把合法憑證令牌給到“黑馬程序員”這個網(wǎng)站;
  • Resource Server,就是“微信用戶信息”這個服務(wù),用戶在其上擁有一些注冊信息,根據(jù)合法的憑證令牌將信息給到“黑馬程序員”這個網(wǎng)站;
OAuth2.0認(rèn)證授權(quán)過程示意圖

二、準(zhǔn)備工作

本案例中總共涉及四個角色,其中用戶是自然人,不需要準(zhǔn)備;其它三個角色都是程序代碼,需要做一些準(zhǔn)備工作。

我們創(chuàng)建一個父工程: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>

然后,我們依次創(chuàng)建三個子模塊:

  • auth-authorize,表示我們的授權(quán)服務(wù),8081端口;
    依賴信息:
    <dependency>
        <groupId>org.springframework.cloud</groupId>
        <artifactId>spring-cloud-starter-oauth2</artifactId>
        <version>2.2.5.RELEASE</version>
    </dependency>
  • auth-resource,表示我們的資源服務(wù),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>

本篇文章主要講解授權(quán)服務(wù)的實現(xiàn),關(guān)于資源服務(wù)和客戶端的示例在后面的篇文章中演示。

三、授權(quán)碼模式

通過第一節(jié)的示意圖我們知道,授權(quán)服務(wù)的主要作用就是對用戶進行認(rèn)證(用戶密碼登錄),然后將用戶的合法性(授權(quán)碼、訪問令牌)傳遞給客戶端。

所以我們需要一個提供給用戶的登錄功能,還需要保留用戶的賬號密碼,對用戶進行認(rèn)證,這個可以使用WebSecurityConfigurerAdapter進行,這在原先講解Spring Security的時候就說到了,如果不熟悉可以翻看原來的文章,此處不贅述。

@EnableWebSecurity
public class MySecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    private MyUserDetailsService userDetailsService;

    /**
     * 對請求進行鑒權(quán)的配置
     * @param http
     * @throws Exception
     */
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
                .anyRequest().authenticated()
                .and()
                // 沒有權(quán)限進入內(nèi)置的登錄頁面
                .formLogin()
                .and()
                // 暫時關(guān)閉CSRF校驗,允許get請求登出
                .csrf().disable();
    }

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        // 使用userDetailsService進行認(rèn)證
        auth.userDetailsService(userDetailsService);
    }

    /**
     * 密碼加密器,供在UserDetailsService中驗證密碼時使用
     * @return
     */
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

}

相應(yīng)的,我們需要一個UserDetailsService來提供用戶信息。

@Service
public class MyUserDetailsService implements UserDetailsService {
    @Autowired
    private PasswordEncoder passwordEncoder;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        // 為了演示方便,使用內(nèi)存定義用戶的真實賬密及其訪問權(quán)限
        return User
                .withUsername("zhangxun")
                .password(passwordEncoder.encode("mm123"))
                // 設(shè)置當(dāng)前用戶可以擁有的權(quán)限信息,授權(quán)碼模式下,用戶輸入賬密后就擁有該權(quán)限
                .authorities("user:query")
                .build();
    }
}

到此,我們的用戶就可以使用賬密登錄授權(quán)服務(wù)了,但是此時還沒有實現(xiàn)任何一點授權(quán)服務(wù)的功能,所以見下面。

我們先定義token令牌的管理策略,可以選擇:

  • 內(nèi)存管理,默認(rèn)管理策略,即令牌被創(chuàng)建后是保存在單機內(nèi)存中的,因此適合授權(quán)服務(wù)是單機并發(fā)量不大的場景下;
  • JDBC管理,令牌被托管到數(shù)據(jù)庫進行管理,適用于授權(quán)服務(wù)是集群的場景,不同機器之間可以通過數(shù)據(jù)庫來共享token;
  • JWT管理,授權(quán)服務(wù)不需要存儲任何token,只需要對訪問令牌進行計算即可驗證token的合法性,也比較適合授權(quán)服務(wù)是集群的場景,而且是現(xiàn)在比較主流的使用方案;

本案例先使用內(nèi)存管理token,其它方式在后面會介紹到。

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

}

然后,就是我們的授權(quán)服務(wù)核心配置類了:

@Configuration
// 標(biāo)記授權(quán)服務(wù)
@EnableAuthorizationServer
public class MyAuthorizationServerConfig extends AuthorizationServerConfigurerAdapter {

    // 授權(quán)碼服務(wù)
    @Autowired
    private AuthorizationCodeServices authorizationCodeServices;
    // 訪問令牌服務(wù)
    @Autowired
    private AuthorizationServerTokenServices tokenServices;
    // 訪問令牌管理服務(wù)
    @Autowired
    private TokenStore tokenStore;
    // 客戶端服務(wù),由于我們使用了內(nèi)存模式,會自動創(chuàng)建一個默認(rèn)的客戶端服務(wù)
    @Autowired
    private ClientDetailsService clientDetailsService;

    /**
     * 配置客戶端的詳情,提供客戶端的信息
     *
     * 客戶端通過訪問如下地址來獲取授權(quán)碼
     * /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=授權(quán)碼&redirect_uri=http://localhost:8080
     *
     * @param clients
     * @throws Exception
     */
    @Override
    public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
        clients
                // 基于內(nèi)存方式存儲客戶信息
                .inMemory()
                // client_id,分配給客戶端的標(biāo)識
                .withClient("iSchool")
                // secret密鑰,加密存儲
                .secret(new BCryptPasswordEncoder().encode("mysecret"))
                // 當(dāng)前僅開啟授權(quán)碼模式,refresh_token表示開啟刷新令牌
                .authorizedGrantTypes("authorization_code","refresh_token")
                // 允許授權(quán)的范圍,默認(rèn)為空表示允許訪問全部范圍,這個在資源服務(wù)器那里用的到
                .scopes("all")
                // 資源服務(wù)器的ID配置,可以是多個,這個在資源服務(wù)器那里用的到
                .resourceIds("user")
                // 設(shè)置該client_id的主體所擁有的權(quán)限信息,在客戶端模式下生效,在資源服務(wù)器那里用的到
                .authorities("user:query")
                // 需要用戶手動授權(quán),即會彈出界面需要用戶手動點擊授權(quán)
                .autoApprove(false)
                // 重定向地址,這里是第三方客戶端的地址,用來接收授權(quán)服務(wù)器返回的授權(quán)碼
                .redirectUris("http://localhost:8080");
                // 可以通過and()再添加其它的客戶端信息,這里省略
    }

    /**
     * 配置令牌的訪問端點和令牌管理服務(wù)
     * 默認(rèn)的訪問端點如下:
     * /oauth/authorize:授權(quán)端點,獲取授權(quán)碼
     * /oauth/token:令牌端點,獲取訪問令牌
     * /oauth/confirm_access:用戶確認(rèn)授權(quán)提交端點
     * /oauth/error:授權(quán)服務(wù)錯誤信息端點
     * /oauth/check_token:提供給資源服務(wù)訪問的令牌驗證端點
     * /oauth/token_key:提供公有密匙的端點,JWT模式使用
     *
     * @param endpoints
     * @throws Exception
     */
    @Override
    public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
        endpoints
                // 指定授權(quán)碼管理策略
                .authorizationCodeServices(authorizationCodeServices)
                // 指定token管理策略,token會自己生成一個隨機值
                .tokenServices(tokenServices)
                // 指定訪問token的請求方法,實際應(yīng)該使用POST方式,這里為了演示方便使用GET
                .allowedTokenEndpointRequestMethods(HttpMethod.GET);
    }

    /**
     * 配置令牌訪問端點的安全約束
     *
     * @param security
     * @throws Exception
     */
    @Override
    public void configure(AuthorizationServerSecurityConfigurer security) throws Exception {
        security
                // 放開/oauth/check_token這個端點,供資源服務(wù)器調(diào)用來校驗訪問token的合法性
                .checkTokenAccess("permitAll()")
                // 開啟表單認(rèn)證
                .allowFormAuthenticationForClients();
    }

    /**
     * 配置授權(quán)碼模式下授權(quán)碼的存取方式,此時采用內(nèi)存模式
     * @return
     */
    @Bean
    public AuthorizationCodeServices authorizationCodeServices() {
        return new InMemoryAuthorizationCodeServices();
    }

    /**
     * 配置令牌管理服務(wù)
     * @return
     */
    @Bean
    public AuthorizationServerTokenServices tokenServices() {
        DefaultTokenServices services = new DefaultTokenServices();
        // 配置客戶端詳情服務(wù),獲取客戶端的信息
        services.setClientDetailsService(clientDetailsService);
        // 支持刷新令牌
        services.setSupportRefreshToken(true);
        // 配置令牌的存儲方式,此時采用內(nèi)存方式存儲
        services.setTokenStore(tokenStore);
        // 訪問令牌有效時間2小時
        services.setAccessTokenValiditySeconds(7200);
        // 刷新令牌的有效時間3天
        services.setRefreshTokenValiditySeconds(259200);
        return services;
    }

}

具體的說明在如上代碼中都已經(jīng)注釋說明了,到此,我們的授權(quán)碼模式就算完成了。啟動項目后,我們使用瀏覽器模擬第三方客戶端發(fā)起授權(quán)請求:

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

這個請求中包含的內(nèi)容主要有:

  • /oauth/authorize,這是訪問端點,授權(quán)服務(wù)器對外暴露的,用于給第三方客戶端生成授權(quán)碼的接口;
  • client_id,就是授權(quán)服務(wù)器分配給第三方客戶端的標(biāo)識,這里隨便寫一個iSchool,只要授權(quán)服務(wù)器上有這個客戶信息即可;
  • response_type,值code表示需要獲取授權(quán)碼;
  • scope,值為all表示需要申請all這個域的資源訪問權(quán)限,必須和上面配置中的一致;
  • redirect_uri,即第三方客戶端的回調(diào)地址,用來獲取授權(quán)服務(wù)器返回的授權(quán)碼;

請求發(fā)起后,頁面就會進入登錄頁面,要求輸入賬密進行登錄,此處即MyUserDetailsService中寫死的zhangxun/mm123,登錄成功后,就會跳轉(zhuǎn)到授權(quán)頁面,

授權(quán)頁面

需要注意到,授權(quán)頁面有很多信息:

  • 授權(quán)給誰?這里是iSchool這個client_id;
  • 授權(quán)的范圍?是all這個域的資源;

登錄頁面和授權(quán)頁面都是可以定制的,這里為了簡單演示,不做過度展開。

當(dāng)我們授權(quán)成功后,授權(quán)服務(wù)器就重定向到第三方客戶端的地址,并帶過來一個授權(quán)碼:

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

第三方客戶端拿到這個授權(quán)碼之后,就將其傳遞給自己的后端服務(wù)器,由后端服務(wù)器再去調(diào)用授權(quán)服務(wù)器換取訪問token。

這里并不是說一定要由后端服務(wù)器去獲取token,而是token是一種需要保護的令牌,我們當(dāng)然可以通過前端直接去獲取token,但這會導(dǎo)致token被泄露在前端,而且還有第三方客戶端的密鑰,這些都是需要保密的內(nèi)容。這里為了方便演示,就直接通過瀏覽器,使用前端調(diào)用授權(quán)服務(wù)器獲取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。

四、簡化模式

簡化模式就是對授權(quán)碼模式進行了簡化,即第三方客戶端訪問授權(quán)服務(wù)器時不需要先獲取授權(quán)碼再獲取訪問token了,而是直接一步到位獲取訪問token。

首先,我們需要在授權(quán)服務(wù)器端的授權(quán)配置中開啟簡化模式:

// 支持的授權(quán)模式,refresh_token表示開啟刷新令牌
.authorizedGrantTypes("implicit","refresh_token")

然后啟動授權(quán)服務(wù)器即可,我們模擬第三方客戶端對授權(quán)服務(wù)器發(fā)起請求如下,注意response_type改為了token:

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

經(jīng)過登錄和授權(quán)之后,授權(quán)服務(wù)器就會重定向到第三方客戶端的地址,并帶回來訪問token:

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

這樣,第三方客戶端就拿到了訪問token,確實簡化了不少,甚至都不用密鑰,但是缺點也很明顯,訪問token在前端有泄露的風(fēng)險,主要用于那些沒有后端服務(wù)的第三方單頁面應(yīng)用,不是很推薦。

五、密碼模式

密碼模式是在授權(quán)碼模式的基礎(chǔ)上,將用戶的賬號密碼給到第三方客戶端,由第三方客戶端帶著用戶的賬密,以及它自己的標(biāo)識和密鑰來訪問授權(quán)服務(wù)器,直接獲取訪問token,由此可以不用用戶在授權(quán)服務(wù)器上進行登錄和授權(quán)操作。

首先,我們需要開啟密碼模式:

.authorizedGrantTypes("password","refresh_token")

其次,為了支持第三方客戶端可以將用戶的賬密帶過來給到授權(quán)服務(wù)器,我們還需要在如上的MySecurityConfig類中增加認(rèn)證管理器:

/**
     * 認(rèn)證管理器,供密碼模式下認(rèn)證用戶時使用
     * @return
     * @throws Exception
     */
@Bean
@Override
public AuthenticationManager authenticationManager() throws Exception {
    return super.authenticationManager();
}

然后在我們的授權(quán)服務(wù)配置類MyAuthorizationServerConfig中使用這個認(rèn)證管理器:

@Autowired
private AuthenticationManager authenticationManager;

@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
    endpoints
        // 指定認(rèn)證管理器,在WebSecurityConfigurerAdapter的實現(xiàn)類中注入,密碼模式需要用到
        .authenticationManager(authenticationManager)
}

好了,現(xiàn)在啟動授權(quán)服務(wù)后,模擬第三方客戶端的后端服務(wù)對授權(quán)服務(wù)器發(fā)起請求如下:

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

得到的返回內(nèi)容為:

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

這樣,第三方客戶端就拿到了訪問token,但是,需要用戶將自己在授權(quán)服務(wù)器上的賬密泄露給第三方客戶端,這對于很多授權(quán)服務(wù)方來說是不可忍受的,除非第三方客戶端就是自己方的應(yīng)用。

六、客戶端模式

客戶端模式也比較簡單,只需要第三方客戶端給出自己的標(biāo)識和密鑰,授權(quán)服務(wù)就返回給它訪問token,甚至都不用用戶的授權(quán)行為。

首先,我們需要開啟客戶端模式:

.authorizedGrantTypes("client_credentials","refresh_token")

然后可以將上述密碼模式添加的認(rèn)證管理器予以刪除,重啟授權(quán)服務(wù)器即可。

模擬第三方客戶端的后端服務(wù)對授權(quán)服務(wù)器發(fā)起請求如下:

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

得到的返回內(nèi)容如下:

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

這樣,第三方客戶端就拿到了訪問token,但是,這中間根本沒有讓用戶進行授權(quán),不能確保第三方客戶端是否會對客戶的信息用作非法用途,因此,只有第三方客戶端是完全授信的情況下才能使用。

七、總結(jié)

綜上四種模式中,授權(quán)碼模式是最復(fù)雜,但是最安全的,也是現(xiàn)在業(yè)內(nèi)最流行使用的方式;簡化模式會導(dǎo)致訪問token泄露到前端,安全性得不到保證;密碼模式和客戶端模式要求第三方客戶端是受控制的,能得到完全信任的情況。

八、思考

7.1 授權(quán)碼的必要性是什么?直接返回訪問token不行嗎?

不行。

  • 授權(quán)碼是為了將瀏覽器地址重定向到第三方客戶端的網(wǎng)址,同時告知一個授權(quán)碼;
  • 授權(quán)碼即使泄露,沒有第三方客戶端的密鑰也是無法獲取訪問token的;
  • 訪問token是需要保護的令牌,不能在前端出現(xiàn);

7.2 如何確保第三方客戶端只能拿到授權(quán)用戶的信息?

待研究

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容