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閱讀 228,606評論 6 533
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,582評論 3 418
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 176,540評論 0 376
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,028評論 1 314
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,801評論 6 410
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,223評論 1 324
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,294評論 3 442
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,442評論 0 289
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,976評論 1 335
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,800評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,996評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,543評論 5 360
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,233評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,662評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,926評論 1 286
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,702評論 3 392
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,991評論 2 374

推薦閱讀更多精彩內容