2015年10月16日 星期五

如何使用oAuth2.0 整合至 Web Application(using Google Youtube Data API v3)

前言:
   去年有寫一篇 如何將live stream發佈至至YouTube,該篇其實已經有使用到oAuth2.0的授權認證機制,但該篇寫法僅適用於單機版本的應用程式,若想在Web Application整合oAuth2.0授權驗證機制,那麼便需要另一種作法,該作法是此篇要講述的重點,若完全沒有操作過YouTube Data API的人建議先看過該篇,因為有些前面講過的設定在這邊就不再贅述。

   由於Google APIs是利用oAuth2.0來對user做驗證與授權,因此在開始進入本篇的重點之前,要先確保大家都了解何謂oAuth2.0。
oAuth2.0用最簡單白話的舉例來解釋,就是當我們的Web Application要提供user操作管理該名user在Youtube的影音服務時,我們的Web Application必須先取得該名user在Google平台上對我們Web Application的授權,因此需執行以下的流程:
  1.驗證user的身分:在後面的實作會將user導到Google的user登入驗證頁面,通過驗證後,會詢問user是否同意授權我們的應用程式管理user的Youtube。
  2.取得user的授權後,我們的Web Application才能開始針對user的Youtube做管理操作(在這邊的範例我們要將影音上傳至user在youtube上的頻道。
整個oAuth2.0就是一個驗證+授權的一連串流程整合在一起的機制,更詳盡的圖文說明請參考
Using OAuth 2.0 to Access Google APIs


準備工作:
1.請先準備好一組OAuth2.0使用的憑證(Credential),若沒有憑證,請至Google Developers Console,然後如下圖:

新增OAuth2.0的憑證,進去後我是選擇"其他",理論上應該選擇"網路應用程式",但我想應該是都可以,各位自行試試看,重點是建立成功後所取得的 用戶端ID用戶端密碼 ,這兩個值很重要,在之後的程式碼中會再使用到,請先記得有這件事情。
2.環境的準備:我的開發環境是 Eclipse 4.5 (Kepler),Tomcat7.0,JDK1.6,同時是使用Spring+Struts2+hibernate,這裡ssh不是重點,只有使用servlet也沒問題,各位只要使用習慣熟悉的環境即可
3.相關Library的準備:一種比較快的方式是利用Google Plugin的工具來下載相關的Google API Lib,而在使用Google Plugin之前必須先安裝此Google Plugin,安裝方式如後:在Eclipse的選單選擇 Help>>Installed New Softwares,然後參考此頁貼入Direct plugin link work with 中,將Google plugin 打勾,要看詳細的圖文說明可參考 Google Plugin for Eclipse
另一種取得Google APIs Lib的方式是直接在 Supported Google APIs 找到我們要的 API項目,這裡我們要下載的是 YouTube Data API v3。
4.假設各位已經取得所需的YouTube Data API Lib,請將取得的jar檔加入到WEB-INF/lib底下,各位的lib底下應該最少要有下面這些jar檔:
  • google-api-client-1.20.0.jar
  • google-api-client-servlet-1.20.0.jar
  • google-oauth-client-1.20.0.jar
  • google-oauth-client-servlet-1.20.0.jar
  • google-http-client-1.20.0.jar
  • commons-logging-1.1.1.jar
  • gson-2.1.jar
  • httpclient-4.0.1.jar
  • httpcore-4.0.1.jar
  • jackson-core-asl-1.9.11.jar
  • jackson-core-2.1.3.jar
  • jdo2-api-2.3-eb.jar
  • jsr305-1.3.9.jar
  • protobuf-java-2.4.1.jar
  • transaction-api-1.1.jar
  • xpp3-1.1.4c.jar (我的裡面沒有)
基本上不論你是用 Google Plugin的工具取得lib,還是自行下載,按照Setup Instructions裡所列,你應該要有如上的jar檔,但其實我是用Google Plugin工具取得,並沒有最後一個xpp3-1.1.4c.jar檔,一樣可以運作,因此如果各位跟我一樣,那麼應該是沒有關係的。



開始程式實作與設定
1. Authorization Servlet的實作: 在這裡我們必須新增兩支Servlet分別去繼承 AbstractAuthorizationCodeServletAbstractAuthorizationCodeCallbackServlet 並且覆寫其中特定的方法,讓我們的Web Application可以很容易的跟Google的身分驗證與授權流程串接起來,source code 如下:

package com.gorilla.servlet;

import java.io.File;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;

import javax.servlet.RequestDispatcher;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import com.google.api.client.auth.oauth2.AuthorizationCodeFlow;
import com.google.api.client.auth.oauth2.AuthorizationCodeRequestUrl;
import com.google.api.client.auth.oauth2.BearerToken;
import com.google.api.client.auth.oauth2.Credential;
import com.google.api.client.auth.oauth2.StoredCredential;
import com.google.api.client.extensions.servlet.auth.oauth2.AbstractAuthorizationCodeServlet;
import com.google.api.client.googleapis.auth.oauth2.GoogleAuthorizationCodeFlow;
import com.google.api.client.http.BasicAuthentication;
import com.google.api.client.http.GenericUrl;
import com.google.api.client.http.javanet.NetHttpTransport;
import com.google.api.client.json.jackson2.JacksonFactory;
import com.google.api.client.util.store.DataStore;
import com.google.api.client.util.store.FileDataStoreFactory;

public class ServletSample extends AbstractAuthorizationCodeServlet {
 public static AuthorizationCodeFlow flow;

 @Override
 public void doGet(HttpServletRequest req, HttpServletResponse response) throws IOException {
   Credential credential = this.getCredential();
if (credential != null) {
 if (credential.getExpiresInSeconds() < 0)
 //if the accessToken expired, refresh the accessToken using refreshToken
   if (credential.refreshToken()){
  System.out.println("refresh the access token's expired date=" + new Date(credential.getExpirationTimeMilliseconds()));
    }
   req.getSession().setAttribute("credential", this.getCredential());
   StringBuffer sb = new StringBuffer();
   sb.append("http://").append(req.getLocalAddr() + ":" + req.getLocalPort()).append(req.getContextPath())
     .append("/showContents.html");
   try {
    response.sendRedirect(sb.toString());
   } catch (IOException e) {
    // TODO Auto-generated catch block
    e.printStackTrace();
   }
  }
  
 }

 @Override
 protected String getRedirectUri(HttpServletRequest req) throws ServletException, IOException {
  // TODO Auto-generated method stub
  StringBuilder sb = new StringBuilder();
  sb.append("http://").append(req.getLocalAddr() + ":" + req.getLocalPort()).append(req.getContextPath())
    .append("/oauth2callback");
  String redirectUri = sb.toString();
  return redirectUri;
 }

 @Override
 protected String getUserId(HttpServletRequest req) throws ServletException, IOException {
  // TODO Auto-generated method stub

  return "root";
 }

 @Override
 protected AuthorizationCodeFlow initializeFlow() throws ServletException, IOException {
  // TODO Auto-generated method stub
  
  String clientID = "xxxxxxxxxxxxxxxx-4hncs25gkhehaqhqqcsnirlli3p6p4i8.apps.googleusercontent.com";
  String password = "meXotHAj2Pa46r0azuzKIAHI";
  List scopes = new ArrayList();
  scopes.add("https://www.googleapis.com/auth/youtube");
  String CREDENTIALS_DIRECTORY = ".oauth-credentials";

  File file = new File(System.getProperty("user.home") + "/" + CREDENTIALS_DIRECTORY);
 
  FileDataStoreFactory fileDataStoreFactory = new FileDataStoreFactory(file);
  DataStore datastore = fileDataStoreFactory.getDataStore("youtube");
 
 flow = new GoogleAuthorizationCodeFlow.Builder(
          new NetHttpTransport(), JacksonFactory.getDefaultInstance(),
          clientID, password,
          scopes).setDataStoreFactory(
           fileDataStoreFactory).setAccessType("offline").build();
  
  return flow;

 }

 @Override
 protected void onAuthorization(javax.servlet.http.HttpServletRequest req,
   javax.servlet.http.HttpServletResponse resp, AuthorizationCodeRequestUrl authorizationUrl)
     throws javax.servlet.ServletException, IOException {

  Credential credential = flow.loadCredential("root");
  if (credential != null) {
   StringBuilder sb = new StringBuilder();
   sb.append("http://").append(req.getLocalAddr() + ":" + req.getLocalPort()).append(req.getContextPath())
     .append("/showContents.html");
   req.getSession().setAttribute("credential", credential);
  } else {
   super.onAuthorization(req, resp, authorizationUrl);
  }
 }

}

底下將整個身分驗證流程在程式中的執行順序以及個人認為使用method的重點敘述如下:
1.getUserId: 在沒有做過任何身分驗證時,會從ServletSample(繼承AbstractAuthorizationCodeServlet)的getUserId() method開始, 因為我的應用程式只需要一個使用者來操作youtube API,因此在我的程式中我的getUserId始終是return "root",這邊應是要看個人應用程式的需求而定。
2. initializeFlow: 這個method最主要的目的就是產生授權流程物件,如程式碼中的靜態變數flow。在這邊我們需要找一個實體位置用來存放flow對應的Credential(憑證),存放Credential的用意是當使用者已經授權認證過後,該憑證資訊會存放在我們設定的實體位置,如此我們的Web Application在每次要執行使用者的授權驗證前,會先去該實體路徑檢查是否有特定使用者的授權憑證資訊,若有,則不需要再連結到Google去重新執行身分認證流程。 在產生flow物件時,就需要用到我們前面提到的 用戶端ID用戶端密碼,如下面程式碼:
String clientID = "xxxxxxxxxxxx8-qpil7lurqnjvho7qphh5tfj3tru7vl3a.apps.googleusercontent.com";
  String password = "_WJYpeI_d09UxNPkAxxxxxx";
  List scopes = new ArrayList();
  scopes.add("https://www.googleapis.com/auth/youtube");
  String CREDENTIALS_DIRECTORY = ".oauth-credentials";
  //credential 資訊要存放的目錄
  File file = new File(System.getProperty("user.home") + "/" + CREDENTIALS_DIRECTORY);
  
  FileDataStoreFactory fileDataStoreFactory = new FileDataStoreFactory(file);
  DataStore datastore = fileDataStoreFactory.getDataStore("youtube");
 

 flow = new GoogleAuthorizationCodeFlow.Builder(
          new NetHttpTransport(), JacksonFactory.getDefaultInstance(),
          clientID, password,
          scopes).setDataStoreFactory(
           fileDataStoreFactory).setAccessType("offline").build();
   return flow;
這邊注意要使用 GoogleAuthorizationCodeFlow來建立flow,並且設定accessTypeoffline,如此才有
refresh token可以更新access token,access token的存續期只有1小時,因此我在doGet()會特別去判定
access token是否過去,若過去須執行token的更新,如第7點的程式內容


3.getRedirectUri:取得AuthorizationCodeFlow物件後,要設定當使用者通過Google的身分
驗證後,系統會將使用者重新導向到哪一個頁面,底下看一下我web.xml的設定:
<servlet>
        <servlet-name>oauth2callback</servlet-name>
        <servlet-class>com.gorilla.servlet.AuthorizationCodeCallbackServlet</servlet-class>
        <load-on-startup>1</load-on-startup>
   </servlet>

    <servlet-mapping>
        <servlet-name>oauth2callback</servlet-name>
        <url-pattern>/oauth2callback</url-pattern>
    </servlet-mapping>
   
我的AuthorizationCodeCallbackServlet是(必須)繼承上面所提到的AbstractAuthorizationCodeCallbackServlet,因此我的getRedirectUri()必須有如下的code:
sb.append("http://").append("127.0.0.1:" + req.getLocalPort()).append(req.getContextPath())
  .append("/oauth2callback");
同時這邊要提醒,在你的GoogleDeveloperConsole的憑證畫面裡,也必須有如下的設定: 也就是你getRedirectUri 所return 的URL必須與你在console內設定的一致。 4.onAuthorization:這個method主要就是用來判斷AuthorizationCodeFlow物件是否能取得Credential物件,若能,則無須經過Google的身分認證流程,否則則進入Google的身分認證流程。當第一次執行程式時,一定取不到Credentail物件,則會進入如下的畫面: 5.若Google驗證程序成功,則流程會執行回呼Servlet,也就是在流程3提到的AuthorizationCodeCallbackServlet,首先會去執行它的initializeFlow(),這裡很重要的一點: 這個method回傳的AuthorizationCodeFlow物件是參考到ServletSample.flow物件,若不這樣參考,是取不到正確的Credential物件,程式如下:
@Override
protected AuthorizationCodeFlow initializeFlow() throws ServletException, IOException {
  // TODO Auto-generated method stub
  return ServletSample.flow;
 }
底下列出完整的AuthorizationCodeCallbackServlet程式碼:
package com.gorilla.servlet;

import java.io.File;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import com.google.api.client.auth.oauth2.AuthorizationCodeFlow;
import com.google.api.client.auth.oauth2.AuthorizationCodeResponseUrl;
import com.google.api.client.auth.oauth2.Credential;
import com.google.api.client.auth.oauth2.StoredCredential;
import com.google.api.client.extensions.servlet.auth.oauth2.AbstractAuthorizationCodeCallbackServlet;
import com.google.api.client.googleapis.auth.oauth2.GoogleAuthorizationCodeFlow;
import com.google.api.client.http.javanet.NetHttpTransport;
import com.google.api.client.json.jackson2.JacksonFactory;
import com.google.api.client.util.store.DataStore;
import com.google.api.client.util.store.FileDataStoreFactory;

public class AuthorizationCodeCallbackServlet extends AbstractAuthorizationCodeCallbackServlet {
 private static String firstPage = "" ;

@Override
protected void onSuccess(HttpServletRequest req, HttpServletResponse resp, Credential credential)
   throws ServletException, IOException {
  StringBuilder sb = new StringBuilder();
  
  sb.append("http://").append(req.getLocalAddr() + ":" + req.getLocalPort()).append(req.getContextPath())
    .append("/showContents.html");
  req.getSession().setAttribute("credential", credential);
  resp.sendRedirect(sb.toString());
 }

 
@Override
protected String getRedirectUri(HttpServletRequest req) throws ServletException, IOException {
  // TODO Auto-generated method stub
  StringBuilder sb = new StringBuilder();

  
  sb.append("http://").append("127.0.0.1:" + req.getLocalPort()).append(req.getContextPath())
  .append("/oauth2callback");
  
  String redirectUri = sb.toString();

  return redirectUri;
 }
 

@Override
protected String getUserId(HttpServletRequest req) throws ServletException, IOException {
  // TODO Auto-generated method stub
  return "root";
 }


@Override
protected void onError(HttpServletRequest req, HttpServletResponse resp, AuthorizationCodeResponseUrl errorResponse)
   throws ServletException, IOException {
  // handle error

  String error = errorResponse.getError();
  System.out.println(error);
 }

 
@Override
protected AuthorizationCodeFlow initializeFlow() throws ServletException, IOException {
  // TODO Auto-generated method stub
  return ServletSample.flow;
 }

}

6.接下來會再跑至getRedirectUri與getUserId等method,最後跑至onSuccess method,在這個method我先將Credential物件存至session中,再redirect至顯示youtube平台影音list的頁面,程式如下:
@Override
protected void onSuccess(HttpServletRequest req, HttpServletResponse resp, Credential credential)  throws ServletException, IOException {
  StringBuilder sb = new StringBuilder();
  
       sb.append("http://").append(req.getLocalAddr() + ":" + req.getLocalPort()).append(req.getContextPath()).append("/showContents.html");
  req.getSession().setAttribute("credential", credential);
  resp.sendRedirect(sb.toString());
 }
7.以上,基本上到第六點完成,就已經完成使用者第一次登入驗證的完整流程,接下來當你在執行同樣的程式,以我的程式來說,我只要輸入http://localhost:8080/ExMedia/authorize,又會再度進入驗證流程,但因為已經有執行過Google的身分驗證流程,且Credential資訊已經寫入指定的實體位置,因此再度執行時,會直接跳過SampleServlet 流程1~6的step,直接進入doGet ,如下列程式碼 :
@Override
public void doGet(HttpServletRequest req, HttpServletResponse response) throws IOException {
  Credential credential = this.getCredential();
  if (credential != null) {
 if (credential.getExpiresInSeconds() < 0)
   //if the accessToken expired, refresh the accessToken using refreshToken
    if (credential.refreshToken()){
  System.out.println("refresh the access token's expired date=" + new Date(credential.getExpirationTimeMilliseconds()));
    }
     
 req.getSession().setAttribute("credential", this.getCredential());
   
 StringBuffer sb = new StringBuffer();
 sb.append("http://").append(req.getLocalAddr() + ":" + req.getLocalPort()).append(req.getContextPath()).append("/showContents.html");
  try {
   response.sendRedirect(sb.toString());
  } catch (IOException e) {
   // TODO Auto-generated catch block
   e.printStackTrace();
  }
   }
}
備註:
  • Servie Account 無法與YouTube Data API 做整合(本文實作時原本就是想以Service Account來與YouTube Data API做整合)
  • 相關可參考的資源與文獻: