SpringBoot學習筆記(十五:OAuth2 )

三分惡發表於2020-06-03

@


一、OAuth 簡介


在這裡插入圖片描述

1、什麼是OAuth

開放授權(Open Authorization,OAuth)是一種資源提供商用於授權第三方應用代表資源所有者獲取有限訪問許可權的授權機制。由於在整個授權過程中,第三方應用都無須觸及使用者的密碼就可以取得部分資源的使用許可權,所以OAuth是安全開放的。

例如,使用者想通過 QQ 登入csdn,這時csdn就是一個第三方應用,csdn要訪問使用者的一些基本資訊就需要得到使用者的授權,如果使用者把自己的 QQ 使用者名稱和密碼告訴csdn,那麼csdn就能訪問使用者的所有資料,井且只有使用者修改密碼才能收回授權,這種授權方式安全隱患很大,如果使用 OAuth ,就能很好地解決這一問題。

在這裡插入圖片描述

OAuth第一個版本誕生於2007年12月,並於2010年4月正式被IETF作為標準釋出(編號RFC 5849)。由於OAuth1.0複雜的簽名邏輯以及單一的授權流程存在較大缺陷,隨後標準工作組又推出了 OAuth2.0草案,並在2012年10月正式釋出其標準(編號RFC 6749)。OAuth2.0放棄了OAuth1.0中讓開發者感到痛苦的數字簽名和加密方案,使用已經得到驗證並廣泛使用的HTTPS技術作為安全保障手 段。OAuth2.0與OAuth1.0互不相容,由於OAuth1.0已經基本退出歷史舞臺,所以下面提到的OAuth都是指OAuth2.0。


2、OAuth 角色

想要理解OAuth的執行流程,則必須要認識4個重要的角色。

  • Resource Owner:資源所有者,通常指使用者,例如每一個QQ使用者。
  • Resource Server:資源伺服器,指存放使用者受保護資源的伺服器,通常需要通過Access Token(訪問令牌)才能進行訪問。例如,儲存QQ使用者基本資訊的伺服器,充當的便是資源伺服器的 角色。
  • Client:客戶端,指需要獲取使用者資源的第三方應用,如CSDN網站。
  • Authorization Server:授權伺服器,用於驗證資源所有者,並在驗證成功之後向客戶端發放相關訪問令牌。

3、OAuth 授權流程

這是 個大致的流程,因為 OAuth2 中有 種不同的授權模式,每種授權模式的授權流程又會有差異,基本流程如下:

  • 客戶端(第三方應用)向資源所有者請求授權。
  • 服務端返回一個授權許可憑證給客戶端。
  • 客戶端拿著授權許可憑證去授權伺服器申請令牌。
  • 授權伺服器驗證資訊無誤後,發放令牌給客戶端。
  • 客戶端拿著令牌去資源伺服器訪問資源。
  • 資源伺服器驗證令牌無誤後開放資源。

在這裡插入圖片描述


4、OAuth授權模式

OAuth 協議的授權模式共分為4種。


4.1、授權碼

授權碼(authorization code)方式,指的是第三方應用先申請一個授權碼,然後再用該碼獲取令牌。

這種方式是最常用的流程,安全性也最高,它適用於那些有後端的 Web 應用。授權碼通過前端傳送,令牌則是儲存在後端,而且所有與資源伺服器的通訊都在後端完成。這樣的前後端分離,可以避免令牌洩漏。

  • 第一步,A 網站提供一個連結,使用者點選後就會跳轉到 B 網站,授權使用者資料給 A 網站使用。下面就是 A 網站跳轉 B 網站的一個示意連結。
https://b.com/oauth/authorize?
  response_type=code&
  client_id=CLIENT_ID&
  redirect_uri=CALLBACK_URL&
  scope=read

上面 URL 中,response_type參數列示要求返回授權碼(code),client_id引數讓 B 知道是誰在請求,redirect_uri引數是 B 接受或拒絕請求後的跳轉網址,scope參數列示要求的授權範圍(這裡是只讀)。

在這裡插入圖片描述

  • 第二步,使用者跳轉後,B 網站會要求使用者登入,然後詢問是否同意給予 A 網站授權。使用者表示同意,這時 B 網站就會跳回redirect_uri引數指定的網址。跳轉時,會傳回一個授權碼,就像下面這樣。
https://a.com/callback?code=AUTHORIZATION_CODE

上面 URL 中,code引數就是授權碼。

在這裡插入圖片描述

  • 第三步,A 網站拿到授權碼以後,就可以在後端,向 B 網站請求令牌。
https://b.com/oauth/token?
 client_id=CLIENT_ID&
 client_secret=CLIENT_SECRET&
 grant_type=authorization_code&
 code=AUTHORIZATION_CODE&
 redirect_uri=CALLBACK_URL

上面 URL 中,client_id 引數和 client_secret 引數用來讓 B 確認 A 的身份(client_secret引數是保密的,因此只能在後端發請求),grant_type引數的值是 AUTHORIZATION_CODE,表示採用的授權方式是授權碼,code引數是上一步拿到的授權碼,redirect_uri 引數是令牌頒發後的回撥網址。

在這裡插入圖片描述

  • 第四步,B 網站收到請求以後,就會頒發令牌。具體做法是向redirect_uri指定的網址,傳送一段 JSON 資料。

    {    
      "access_token":"ACCESS_TOKEN",
      "token_type":"bearer",
      "expires_in":2592000,
      "refresh_token":"REFRESH_TOKEN",
      "scope":"read",
      "uid":100101,
      "info":{...}
    }

上面 JSON 資料中,access_token欄位就是令牌,A 網站在後端拿到了。

在這裡插入圖片描述

4.2、隱藏式

有些 Web 應用是純前端應用,沒有後端。這時就不能用上面的方式了,必須將令牌儲存在前端。RFC 6749 就規定了第二種方式,允許直接向前端頒發令牌。這種方式沒有授權碼這個中間步驟,所以稱為(授權碼)"隱藏式"(implicit)。

  • 第一步,A 網站提供一個連結,要求使用者跳轉到 B 網站,授權使用者資料給 A 網站使用。
https://b.com/oauth/authorize?
  response_type=token&
  client_id=CLIENT_ID&
  redirect_uri=CALLBACK_URL&
  scope=read

上面 URL 中,response_type引數為token,表示要求直接返回令牌。

  • 第二步,使用者跳轉到 B 網站,登入後同意給予 A 網站授權。這時,B 網站就會跳回redirect_uri引數指定的跳轉網址,並且把令牌作為 URL 引數,傳給 A 網站。
https://a.com/callback#token=ACCESS_TOKEN

上面 URL 中,token引數就是令牌,A 網站因此直接在前端拿到令牌。

注意,令牌的位置是 URL 錨點(fragment),而不是查詢字串(querystring),這是因為 OAuth 2.0 允許跳轉網址是 HTTP 協議,因此存在"中間人攻擊"的風險,而瀏覽器跳轉時,錨點不會發到伺服器,就減少了洩漏令牌的風險。

在這裡插入圖片描述
這種方式把令牌直接傳給前端,是很不安全的。因此,只能用於一些安全要求不高的場景,並且令牌的有效期必須非常短,通常就是會話期間(session)有效,瀏覽器關掉,令牌就失效了。


4.3、密碼式

如果你高度信任某個應用,RFC 6749 也允許使用者把使用者名稱和密碼,直接告訴該應用。該應用就使用你的密碼,申請令牌,這種方式稱為"密碼式"(password)。

  • 第一步,A 網站要求使用者提供 B 網站的使用者名稱和密碼。拿到以後,A 就直接向 B 請求令牌。
https://oauth.b.com/token?
  grant_type=password&
  username=USERNAME&
  password=PASSWORD&
  client_id=CLIENT_ID

上面 URL 中,grant_type引數是授權方式,這裡的password表示"密碼式",username和password是 B 的使用者名稱和密碼。

  • 第二步,B 網站驗證身份通過後,直接給出令牌。注意,這時不需要跳轉,而是把令牌放在 JSON 資料裡面,作為 HTTP 回應,A 因此拿到令牌。

4.4、憑證式

最後一種方式是憑證式(client credentials),適用於沒有前端的命令列應用,即在命令列下請求令牌。

  • 第一步,A 應用在命令列向 B 發出請求。
https://oauth.b.com/token?
  grant_type=client_credentials&
  client_id=CLIENT_ID&
  client_secret=CLIENT_SECRET

上面 URL 中,grant_type引數等於client_credentials表示採用憑證式,client_id和client_secret用來讓 B 確認 A 的身份。

  • 第二步,B 網站驗證通過以後,直接返回令牌。

這種方式給出的令牌,是針對第三方應用的,而不是針對使用者的,即有可能多個使用者共享同一個令牌。


二、實踐

1、密碼模式

如果是自建單點服務,一般都會使用密碼模式。資源伺服器和授權伺服器
可以是同一臺伺服器,也可以分開。這裡我們學習分散式的情況。

授權伺服器和資源伺服器分開,專案結構如下:

在這裡插入圖片描述


1.1、授權伺服器

授權伺服器的職責:

  • 管理客戶端及其授權資訊
    * 管理使用者及其授權資訊
    * 管理Token的生成及其儲存
    * 管理Token的校驗及校驗Key

1.1.1、依賴

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

1.1.2、授權伺服器配置

授權伺服器配置通過繼承AuthorizationServerConfigurerAdapter的配置類實現:

/**
 * @Author 三分惡
 * @Date 2020/5/20
 * @Description  授權伺服器配置
 */
@Configuration
@EnableAuthorizationServer
public class AuthorizationServerConfig extends AuthorizationServerConfigurerAdapter {

    @Autowired
    private AuthenticationManager authenticationManager;//密碼模式需要注入認證管理器

    @Autowired
    public PasswordEncoder passwordEncoder;

    //配置客戶端
    @Override
    public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
        clients.inMemory()
                .withClient("client-demo")
                .secret(passwordEncoder.encode("123"))
                .authorizedGrantTypes("password") //這裡配置為密碼模式
                .scopes("read_scope");
    }

    @Override
    public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
        endpoints.authenticationManager(authenticationManager);//密碼模式必須新增authenticationManager
    }

    @Override
    public void configure(AuthorizationServerSecurityConfigurer security) throws Exception {
        security.allowFormAuthenticationForClients()
                .checkTokenAccess("isAuthenticated()");
    }
}

  • 客戶端的註冊:這裡通過inMemory的方式在記憶體中註冊客戶端相關資訊;實際專案中可以通過一些管理介面及介面動態實現客戶端的註冊
  • 校驗Token許可權控制:資源伺服器如果需要呼叫授權伺服器的/oauth/check_token介面校驗token有效性,那麼需要配置checkTokenAccess("isAuthenticated()")
  • authenticationManager配置:需要通過endpoints.authenticationManager(authenticationManager)將Security中的authenticationManager配置到Endpoints中,否則,在Spring Security中配置的許可權控制將不會在進行OAuth2相關許可權控制的校驗時生效。

1.1.3、Spring Security配置

通過Spring Security來完成使用者及密碼加解密等配置:

/**
 * @Author 三分惡
 * @Date 2020/5/20
 * @Description SpringSecurity 配置
 */
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

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

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.inMemoryAuthentication()
                .withUser("fighter")
                .password(passwordEncoder().encode("123"))
                .authorities(new ArrayList<>(0));
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        //所有請求必須認證
        http.authorizeRequests().anyRequest().authenticated();
    }
}

1.2、資源伺服器

資源伺服器的職責:

  • token的校驗
  • 給與資源

1.2.1、資源伺服器配置

資源伺服器依賴一樣,而配置則通過繼承自ResourceServerConfigurerAdapter的配置類來實現:

/**
 * @Author 三分惡
 * @Date 2020/5/20
 * @Description
 */
@Configuration
@EnableResourceServer
public class ResourceServerConfig extends ResourceServerConfigurerAdapter {
    @Bean
    public RemoteTokenServices remoteTokenServices() {
        final RemoteTokenServices tokenServices = new RemoteTokenServices();
        tokenServices.setClientId("client-demo");
        tokenServices.setClientSecret("123");
        tokenServices.setCheckTokenEndpointUrl("http://localhost:8090/oauth/check_token");
        return tokenServices;
    }

    @Override
    public void configure(ResourceServerSecurityConfigurer resources) throws Exception {
        resources.stateless(true);
    }

    @Override
    public void configure(HttpSecurity http) throws Exception {
        //session建立策略
        http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED);
        //所有請求需要認證
        http.authorizeRequests().anyRequest().authenticated();
    }
}

主要進行了如下配置:

  • TokenService配置:在不採用JWT的情況下,需要配置RemoteTokenServices來充當tokenServices,它主要完成Token的校驗等工作。因此需要指定校驗Token的授權伺服器介面地址
  • 同時,由於在授權伺服器中配置了/oauth/check_token需要客戶端登入後才能訪問,因此也需要配置客戶端編號及Secret;在校驗之前先進行登入
  • 通過ResourceServerSecurityConfigurer來配置需要訪問的資源編號及使用的TokenServices

1.2.2、資源服務介面

介面比較簡單:

/**
 * @Author 三分惡
 * @Date 2020/5/20
 * @Description
 */
@RestController
public class ResourceController {

    @GetMapping("/user/{username}")
    public String user(@PathVariable String username){
        return "Hello !"+username;
    }
}

1.3、測試

授權伺服器使用8090埠啟動,資源伺服器使用預設埠。


1.3.1、獲取token

訪問/oauth/token端點,獲取token:

在這裡插入圖片描述

  • 請求頭:

在這裡插入圖片描述

  • 返回的token
    在這裡插入圖片描述

1.3.2、使用獲取到的token訪問資源介面

  • 使用token呼叫資源,訪問http://localhost:8080/user/fighter,注意使用token新增Bearer請求頭

在這裡插入圖片描述
相當於在Headers中新增 Authorization:Bearer 4a3c351d-770d-42aa-af39-3f54b50152e9。

OK,可以看到資源正確返回。

這裡僅僅是密碼模式的精簡化配置,在實際專案中,某些部分如:

  • 資源服務訪問授權服務去校驗token這部分可能會換成Jwt、Redis等tokenStore實現,
  • 授權伺服器中的使用者資訊與客戶端資訊生產環境從資料庫中讀取,對應Spring Security的UserDetailsService實現類或使用者資訊的Provider

2、授權碼模式

很多網站登入時,允許使用第三方網站的身份,這稱為"第三方登入"。所謂第三方登入,實質就是 OAuth 授權。

例如使用者想要登入 A 網站,A 網站讓使用者提供第三方網站的資料,證明自己的身份。獲取第三方網站的身份資料,就需要 OAuth 授權。

以A網站使用GitHub第三方登入為例,流程示意如下:

在這裡插入圖片描述

接下來,簡單地實現GitHub登入流程。


2.1、應用註冊

在使用之前需要先註冊一個應用,讓GitHub可以識別。

在這裡插入圖片描述

應用的名稱隨便填,主頁 URL 填寫http://localhost:8080,回撥地址填寫 http://localhost:8080/oauth/redirect。

  • 提交表單以後,GitHub 應該會返回客戶端 ID(client ID)和客戶端金鑰(client secret),這就是應用的身份識別碼
    在這裡插入圖片描述

2.2、具體程式碼

  • 只需要引入web依賴:
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
  • GitHub相關配置
github.client.clientId=29d127aa0753c12263d7
github.client.clientSecret=f3cb9222961efe4c2adccd6d3e0df706972fa5eb
github.client.authorizeUrl=https://github.com/login/oauth/authorize
github.client.accessTokenUrl=https://github.com/login/oauth/access_token
github.client.redirectUrl=http://localhost:8080/oauth/redirect
github.client.userInfoUrl=https://api.github.com/user

  • 對應的配置類
@Component
@ConfigurationProperties(prefix = "github.client")
public class GithubProperties {
    private String clientId;
    private String clientSecret;
    private String authorizeUrl;
    private String redirectUrl;
    private String accessTokenUrl;
    private String userInfoUrl;
    //省略getter、setter
}    
  • index.html:首頁比較簡單,一個連結向後端發起登入請求
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>網站首頁</title>
</head>
<body>
    <div style="text-align: center">
        <a href="http://localhost:8080/authorize">Login in with GitHub</a>
    </div>
</body>
</html>
  • GithubLoginController.java:
     * 使用RestTemplate傳送http請求
     * 使用Jackson解析返回的json,不用引入更多依賴
     * 快捷起見,傳送http請求的方法直接寫在控制器中,實際上應該將工具方法分離出去
     * 同樣是快捷起見,返回的使用者資訊沒有做任何解析
@Controller
public class GithubLoginController {
    @Autowired
    GithubProperties githubProperties;


    /**
     * 登入介面,重定向至github
     *
     * @return 跳轉url
     */
    @GetMapping("/authorize")
    public String authorize() {
        String url =githubProperties.getAuthorizeUrl() +
                "?client_id=" + githubProperties.getClientId() +
                "&redirect_uri=" + githubProperties.getRedirectUrl();
        return "redirect:" + url;
    }

    /**
     * 回撥介面,使用者同意授權後,GitHub會將授權碼傳遞給此介面
     * @param code GitHub重定向時附加的授權碼,只能用一次
     * @return
     */
    @GetMapping("/oauth/redirect")
    @ResponseBody
    public String redirect(@RequestParam("code") String code) throws JsonProcessingException {
        System.out.println("code:"+code);
        // 使用code獲取token
        String accessToken = this.getAccessToken(code);
        // 使用token獲取userInfo
        String userInfo = this.getUserInfo(accessToken);
        return userInfo;
    }


    /**
     * 使用授權碼獲取token
     * @param code
     * @return
     */
    private String getAccessToken(String code) throws JsonProcessingException {
        String url = githubProperties.getAccessTokenUrl() +
                "?client_id=" + githubProperties.getClientId() +
                "&client_secret=" + githubProperties.getClientSecret() +
                "&code=" + code +
                "&grant_type=authorization_code";
        // 構建請求頭
        HttpHeaders requestHeaders = new HttpHeaders();
        // 指定響應返回json格式
        requestHeaders.add("accept", "application/json");
        // 構建請求實體
        HttpEntity<String> requestEntity = new HttpEntity<>(requestHeaders);
        RestTemplate restTemplate = new RestTemplate();
        // post 請求方式
        ResponseEntity<String> response = restTemplate.postForEntity(url, requestEntity, String.class);
        String responseStr = response.getBody();
        // 解析響應json字串
        ObjectMapper objectMapper = new ObjectMapper();
        JsonNode jsonNode = objectMapper.readTree(responseStr);
        String accessToken = jsonNode.get("access_token").asText();
        System.out.println("accessToken:"+accessToken);
        return accessToken;
    }

    /**
     *
     * @param accessToken 使用token獲取userInfo
     * @return
     */
    private String getUserInfo(String accessToken) {
        String url = githubProperties.getUserInfoUrl();
        // 構建請求頭
        HttpHeaders requestHeaders = new HttpHeaders();
        // 指定響應返回json格式
        requestHeaders.add("accept", "application/json");
        // AccessToken放在請求頭中
        requestHeaders.add("Authorization", "token " + accessToken);
        // 構建請求實體
        HttpEntity<String> requestEntity = new HttpEntity<>(requestHeaders);
        RestTemplate restTemplate = new RestTemplate();
        // get請求方式
        ResponseEntity<String> response = restTemplate.exchange(url, HttpMethod.GET, requestEntity, String.class);
        String userInfo = response.getBody();
        System.out.println("userInfo:"+userInfo);
        return userInfo;
    }

}

2.3、測試

  • 訪問localhost:8080,點選連結,重定向至GitHub

在這裡插入圖片描述

  • 在GitHub中輸入賬號密碼,登入

在這裡插入圖片描述

  • 登入成功後,GitHub 就會跳轉到redirect_uri指定的跳轉網址,並且帶上授權碼
http://localhost:8080/oauth/redirect?code=d45683eded3ac7d4e6ed

OK,使用者資訊也一併返回了。

在這裡插入圖片描述


本文為學習筆記類部落格,學習資料見參考!



參考:

【1】:《SpringSecurity 實戰》
【2】:《SpringBoot Vue全棧開發實戰》
【3】:理解OAuth 2.0
【4】:OAuth 2.0 的一個簡單解釋
【5】:OAuth 2.0 的四種方式
【6】:這個案例寫出來,還怕跟面試官扯不明白 OAuth2 登入流程?
【7】:做微服務繞不過的 OAuth2,鬆哥也來和大家扯一扯
【8】:GitHub OAuth 第三方登入示例教程
【9】:OAuth 2.0 認證的原理與實踐
【10】:Spring Security OAuth2 Demo —— 密碼模式(Password)
【11】:Spring Security OAuth專題學習-密碼模式及客戶端模式例項
【12】:Spring Boot and OAuth2
【13】:Spring Boot+OAuth2使用GitHub登入自己的服務

相關文章