使用 Go 驗證使用者

這部分 Go Bookshelf 教學課程說明如何為使用者建立登入流程,以及如何使用個人資料資訊為使用者提供個人化功能。

使用 Google Identity Platform 可以輕鬆存取使用者的相關資訊,同時確信 Google 會安全地代管登入憑證。 OAuth 2.0 可讓您為應用程式的所有使用者輕鬆提供登入流程,並使應用程式可以存取通過驗證之使用者的基本個人資料。

本頁面是多頁教學課程的一部分。如要從頭開始並閱讀設定操作說明,請前往 Go Bookshelf 應用程式頁面。

建立網路應用程式用戶端 ID

網路應用程式用戶端 ID 可讓您的應用程式授權使用者,並代表您的使用者存取 Google API。

  1. 在 Google Cloud Platform 主控台中,前往「Credentials」(憑證) 頁面。

    憑證

  2. 按一下 OAuth 同意畫面。在產品名稱部分輸入 Go Bookshelf App

  3. 在「Authorized Domains」(授權網域) 部分,將您的 App Engine 應用程式名稱新增為 [YOUR_PROJECT_ID].appspot.com。用您的 GCP 專案 ID 取代 [YOUR_PROJECT_ID]

  4. 填寫任何其他相關的選填欄位,按一下 [Save] (儲存)

  5. 按一下 [Create credentials] (建立憑證) > [OAuth client ID] (OAuth 用戶端 ID)

  6. 在「Application type」(應用程式類型) 下拉式清單中,按一下 [Web Application] (網路應用程式)

  7. 在「Name」(名稱) 欄位中輸入 Go Bookshelf Client

  8. 在 [Authorized redirect URIs] (已授權的重新導向 URI) 欄位輸入以下網址,一次輸入一個。

    http://localhost:8080/oauth2callback
    http://[YOUR_PROJECT_ID].appspot.com/oauth2callback
    https://[YOUR_PROJECT_ID].appspot.com/oauth2callback

  9. 按一下 [Create] (建立)

  10. 複製「用戶端 ID」與「用戶端密碼」並儲存,以供日後使用。

設定

  1. 前往包含程式碼範例的目錄:

    Linux/macOS

    cd $GOPATH/src/github.com/GoogleCloudPlatform/golang-samples/getting-started/bookshelf
    

    Windows

    cd %GOPATH%\src\github.com\GoogleCloudPlatform\golang-samples\getting-started\bookshelf
    

  2. 開啟 config.go 進行編輯。

  3. 將下面這一行取消註解:

    // oauthConfig = configureOAuthClient("clientid", "clientsecret")
    
  4. "clientid""clientsecret" 替換為您之前建立的用戶端 ID 與用戶端密碼。

  5. 儲存並關閉 config.go

  6. 編輯 app/app.yaml。在 OAUTH2_CALLBACK 環境變數中,將 <your-project-id> 替換為您的專案 ID。

在本機電腦執行應用程式

  1. 執行應用程式以啟動本機網路伺服器:

    cd app
    go run app.go auth.go template.go
    
  2. 在網路瀏覽器中輸入此位址:

    http://localhost:8080

您現在可以瀏覽應用程式的網頁、使用 Google 帳戶登入、新增書籍,以及使用上方導覽列中的「My Books」(我的書籍) 連結查看您新增的書籍。

按下 Control+C 即可離開本機網路伺服器。

將應用程式部署至 App Engine 彈性環境

  1. app 目錄中,輸入下列指令以部署範例:

    gcloud app deploy
    
  2. 在網路瀏覽器中,輸入下列網址。將 [YOUR_PROJECT_ID] 替換為您的專案 ID:

    https://[YOUR_PROJECT_ID].appspot.com
    

如果您更新應用程式,可輸入與第一次部署應用程式時使用的相同指令部署更新版本。新部署會為您的應用程式建立新版本,並會將它升級為預設版本。 應用程式的較舊版本會保留下來,相關聯的 VM 執行個體也會保留下來。請注意,這些應用程式版本和 VM 執行個體全部都是計費資源。

您可以刪除應用程式的非預設版本來降低費用。

若要刪除應用程式版本:

  1. 前往 GCP 主控台的「App Engine Versions」(App Engine 版本) 頁面。

    前往版本頁面

  2. 找到您要刪除的非預設應用程式版本,然後點選旁邊的核取方塊。
  3. 按一下頁面頂部的 [刪除] 按鈕, 刪除應用程式版本。

如需有關清除計費資源的完整資訊,請參閱本教學課程最後一個步驟的清除一節。

應用程式結構

下圖顯示應用程式的元件,以及這些元件彼此之間的連線方式。

驗證範例結構

瞭解程式碼

這部分內容會逐步引導您瞭解應用程式程式碼,並說明其運作方式。

關於工作階段

您需要先使用某種方法將目前使用者的相關資訊儲存在工作階段中,應用程式才能驗證使用者。Gorilla Web Toolkit 包含一個 Store 介面,其中包括數種實作方式。CookieStore 實作可將工作階段資料儲存在安全的 Cookie 中,而 FilesystemStore 實作可將工作階段資料儲存在檔案系統中。將工作階段資料儲存在檔案系統中並不適合可從多個 VM 執行個體提供的應用程式,因為記錄在一個執行個體中的工作階段可能與其他執行個體不同。因此,Bookshelf 範例會使用加密的 Cookie (CookieStore)。加密的工作階段資料會儲存在使用者的瀏覽器中,且可由執行應用程式程式碼的任何執行個體解密。CookieStore 實作可在您已部署應用程式的多個執行個體的實際工作環境中使用,但請注意,當使用者關閉瀏覽器時,將會失去工作階段資訊。

// Configure storage method for session-wide information.
// Update "something-very-secret" with a hard to guess string or byte sequence.
cookieStore := sessions.NewCookieStore([]byte("something-very-secret"))
cookieStore.Options = &sessions.Options{
	HttpOnly: true,
}
SessionStore = cookieStore

驗證使用者

驗證使用者涉及兩個基本步驟,這兩個步驟一起稱為「網路服務流程」

  1. 將使用者重新導向至 Google 的授權服務。
  2. 在 Google 將使用者重新導向回應用程式時處理回應。

下面我們將詳細說明這兩個步驟:

  1. 重新導向至 Google。

    應用程式會將使用者傳送至 Google 的授權服務。應用程式會使用用戶端 ID 與應用程式使用的 OAuth 範圍產生網址。範圍指示允許應用程式存取的使用者資訊項目。Google 提供了多項服務,且每項服務的範圍都不同。例如,有允許唯讀存取 Google 雲端硬碟的範圍,以及允許讀取/寫入存取權的範圍。這個範例僅需要 emailprofile 基本範圍,這兩個範圍可授權應用程式存取使用者的電子郵件地址與基本個人資料資訊。

    func configureOAuthClient(clientID, clientSecret string) *oauth2.Config {
    	redirectURL := os.Getenv("OAUTH2_CALLBACK")
    	if redirectURL == "" {
    		redirectURL = "http://localhost:8080/oauth2callback"
    	}
    	return &oauth2.Config{
    		ClientID:     clientID,
    		ClientSecret: clientSecret,
    		RedirectURL:  redirectURL,
    		Scopes:       []string{"email", "profile"},
    		Endpoint:     google.Endpoint,
    	}
    }

    loginHandler 函式會將 /login 網址路徑重新導向至 Google 授權服務:

    // loginHandler initiates an OAuth flow to authenticate the user.
    func loginHandler(w http.ResponseWriter, r *http.Request) *appError {
    	sessionID := uuid.Must(uuid.NewV4()).String()
    
    	oauthFlowSession, err := bookshelf.SessionStore.New(r, sessionID)
    	if err != nil {
    		return appErrorf(err, "could not create oauth session: %v", err)
    	}
    	oauthFlowSession.Options.MaxAge = 10 * 60 // 10 minutes
    
    	redirectURL, err := validateRedirectURL(r.FormValue("redirect"))
    	if err != nil {
    		return appErrorf(err, "invalid redirect URL: %v", err)
    	}
    	oauthFlowSession.Values[oauthFlowRedirectKey] = redirectURL
    
    	if err := oauthFlowSession.Save(r, w); err != nil {
    		return appErrorf(err, "could not save session: %v", err)
    	}
    
    	// Use the session ID for the "state" parameter.
    	// This protects against CSRF (cross-site request forgery).
    	// See https://godoc.org/golang.org/x/oauth2#Config.AuthCodeURL for more detail.
    	url := bookshelf.OAuthConfig.AuthCodeURL(sessionID, oauth2.ApprovalForce,
    		oauth2.AccessTypeOnline)
    	http.Redirect(w, r, url, http.StatusFound)
    	return nil
    }

    AuthCodeURL 函式的第一個參數是 state 參數,用來透過防止跨網站偽造要求,保護登入程序的安全。

    應用程式會將使用者重新導向至產生的網址。系統會提示使用者允許應用程式存取您指定的範圍。使用者接受之後,系統會將其重新導向回您的應用程式。

  2. 處理授權回應。

    Google 授權服務會透過 /oauth2callback 網址路徑,將使用者與 code 表單值指定的授權碼一起傳送回您的應用程式。您可以交換這個授權碼與用戶端密碼,以取得使用者憑證與使用者的相關資訊。流程的這一個步驟完全在伺服器端進行,不需要重新導向使用者。系統需要這個額外的步驟來驗證授權碼的真實性,以及您的應用程式是否就是要求憑證的應用程式:

    // oauthCallbackHandler completes the OAuth flow, retreives the user's profile
    // information and stores it in a session.
    func oauthCallbackHandler(w http.ResponseWriter, r *http.Request) *appError {
    	oauthFlowSession, err := bookshelf.SessionStore.Get(r, r.FormValue("state"))
    	if err != nil {
    		return appErrorf(err, "invalid state parameter. try logging in again.")
    	}
    
    	redirectURL, ok := oauthFlowSession.Values[oauthFlowRedirectKey].(string)
    	// Validate this callback request came from the app.
    	if !ok {
    		return appErrorf(err, "invalid state parameter. try logging in again.")
    	}
    
    	code := r.FormValue("code")
    	tok, err := bookshelf.OAuthConfig.Exchange(context.Background(), code)
    	if err != nil {
    		return appErrorf(err, "could not get auth token: %v", err)
    	}
    
    	session, err := bookshelf.SessionStore.New(r, defaultSessionID)
    	if err != nil {
    		return appErrorf(err, "could not get default session: %v", err)
    	}
    
    	ctx := context.Background()
    	profile, err := fetchProfile(ctx, tok)
    	if err != nil {
    		return appErrorf(err, "could not fetch Google profile: %v", err)
    	}
    
    	session.Values[oauthTokenSessionKey] = tok
    	// Strip the profile to only the fields we need. Otherwise the struct is too big.
    	session.Values[googleProfileSessionKey] = stripProfile(profile)
    	if err := session.Save(r, w); err != nil {
    		return appErrorf(err, "could not save session: %v", err)
    	}
    
    	http.Redirect(w, r, redirectURL, http.StatusFound)
    	return nil
    }

    取得使用者的憑證之後,您可以透過 Google+ API 擷取其個人資料資訊。呼叫 plusService.People.Get("me") 可擷取目前已驗證使用者的個人資料。與 googleProfileSessionKey 不同 (使用 encoding/gob 編碼),個人資料會儲存在工作階段中:

    // fetchProfile retrieves the Google+ profile of the user associated with the
    // provided OAuth token.
    func fetchProfile(ctx context.Context, tok *oauth2.Token) (*plus.Person, error) {
    	client := oauth2.NewClient(ctx, bookshelf.OAuthConfig.TokenSource(ctx, tok))
    	plusService, err := plus.New(client)
    	if err != nil {
    		return nil, err
    	}
    	return plusService.People.Get("me").Do()
    }

    由於個人資料資訊儲存在工作階段中,因此可由應用程式擷取,而不會從 Google+ API 再次擷取。

    // profileFromSession retreives the Google+ profile from the default session.
    // Returns nil if the profile cannot be retreived (e.g. user is logged out).
    func profileFromSession(r *http.Request) *Profile {
    	session, err := bookshelf.SessionStore.Get(r, defaultSessionID)
    	if err != nil {
    		return nil
    	}
    	tok, ok := session.Values[oauthTokenSessionKey].(*oauth2.Token)
    	if !ok || !tok.Valid() {
    		return nil
    	}
    	profile, ok := session.Values[googleProfileSessionKey].(*Profile)
    	if !ok {
    		return nil
    	}
    	return profile
    }

    例如,系統會將個人資料傳入基礎範本,以在每個頁面中轉譯已登入使用者的姓名與個人資料相片:

    // Execute writes the template using the provided data, adding login and user
    // information to the base template.
    func (tmpl *appTemplate) Execute(w http.ResponseWriter, r *http.Request, data interface{}) *appError {
    	d := struct {
    		Data        interface{}
    		AuthEnabled bool
    		Profile     *Profile
    		LoginURL    string
    		LogoutURL   string
    	}{
    		Data:        data,
    		AuthEnabled: bookshelf.OAuthConfig != nil,
    		LoginURL:    "/login?redirect=" + r.URL.RequestURI(),
    		LogoutURL:   "/logout?redirect=" + r.URL.RequestURI(),
    	}
    
    	if d.AuthEnabled {
    		// Ignore any errors.
    		d.Profile = profileFromSession(r)
    	}
    
    	if err := tmpl.t.Execute(w, d); err != nil {
    		return appErrorf(err, "could not write template: %v", err)
    	}
    	return nil
    }
    {{if .AuthEnabled}}
      {{if .Profile}}
      <form method="post" action="{{.LogoutURL}}" class="navbar-form navbar-right">
        <button class="btn btn-default">Log out</button>
      </form>
      <div class="navbar-text navbar-right">
        {{if .Profile.ImageURL}}
          <img class="img-circle" width="24" src="{{.Profile.ImageURL}}">
        {{end}}
        <span>{{.Profile.DisplayName}}</span>
      </div>
      {{else}}
      <div class="navbar-text navbar-right">
        <a href="{{.LoginURL}}">Log in</a>
      </div>
      {{end}}
    {{end}}

個人化

現在,您可以透過中介軟體使用已登入使用者的資訊,進而追蹤哪個使用者將哪本書籍新增至資料庫。

// If the form didn't carry the user information for the creator, populate it
// from the currently logged in user (or mark as anonymous).
if book.CreatedByID == "" {
	user := profileFromSession(r)
	if user != nil {
		// Logged in.
		book.CreatedBy = user.DisplayName
		book.CreatedByID = user.ID
	} else {
		// Not logged in.
		book.SetCreatorAnonymous()
	}
}

另外,由於這些資訊儲存在資料庫中,因此您可以向使用者顯示他們親自新增的書籍。

// listMineHandler displays a list of books created by the currently
// authenticated user.
func listMineHandler(w http.ResponseWriter, r *http.Request) *appError {
	user := profileFromSession(r)
	if user == nil {
		http.Redirect(w, r, "/login?redirect=/books/mine", http.StatusFound)
		return nil
	}

	books, err := bookshelf.DB.ListBooksCreatedBy(user.ID)
	if err != nil {
		return appErrorf(err, "could not list books: %v", err)
	}

	return listTmpl.Execute(w, r, books)
}

這個程式碼使用稱為 ListBooksCreatedBy 的資料庫方法。實作情況視您選擇的資料庫後端而定。

Datastore

在 Cloud Datastore 中,您必須為查詢建立其他索引,來篩選多個欄位。在 ListBooksCreatedBy 函式中,這個查詢會篩選 CreatedByIDTitle

// ListBooksCreatedBy returns a list of books, ordered by title, filtered by
// the user who created the book entry.
func (db *datastoreDB) ListBooksCreatedBy(userID string) ([]*Book, error) {
	ctx := context.Background()
	if userID == "" {
		return db.ListBooks()
	}

	books := make([]*Book, 0)
	q := datastore.NewQuery("Book").
		Filter("CreatedByID =", userID).
		Order("Title")

	keys, err := db.client.GetAll(ctx, q, &books)

	if err != nil {
		return nil, fmt.Errorf("datastoredb: could not list books: %v", err)
	}

	for i, k := range keys {
		books[i].ID = k.ID
	}

	return books, nil
}

如要建立索引,請輸入下列指令:

Linux/macOS

cd $GOPATH/src/github.com/GoogleCloudPlatform/golang-samples/getting-started/bookshelf
cd app
gcloud datastore create-indexes index.yaml

Windows

cd %GOPATH%\src\github.com\GoogleCloudPlatform\golang-samples\getting-started\bookshelf
cd app
gcloud datastore create-indexes index.yaml
indexes:

# This index enables filtering by "CreatedByID" and sort by "Title".
- kind: Book
  properties:
  - name: CreatedByID
    direction: asc
  - name: Title
    direction: asc

Cloud SQL

const listByStatement = `
  SELECT * FROM books
  WHERE createdById = ? ORDER BY title`

// ListBooksCreatedBy returns a list of books, ordered by title, filtered by
// the user who created the book entry.
func (db *mysqlDB) ListBooksCreatedBy(userID string) ([]*Book, error) {
	if userID == "" {
		return db.ListBooks()
	}

	rows, err := db.listBy.Query(userID)
	if err != nil {
		return nil, err
	}
	defer rows.Close()

	var books []*Book
	for rows.Next() {
		book, err := scanBook(rows)
		if err != nil {
			return nil, fmt.Errorf("mysql: could not read row: %v", err)
		}

		books = append(books, book)
	}

	return books, nil
}

MongoDB

// ListBooksCreatedBy returns a list of books, ordered by title, filtered by
// the user who created the book entry.
func (db *mongoDB) ListBooksCreatedBy(userID string) ([]*Book, error) {
	var result []*Book
	if err := db.c.Find(bson.D{{Name: "createdbyid", Value: userID}}).Sort("title").All(&result); err != nil {
		return nil, err
	}
	return result, nil
}
本頁內容對您是否有任何幫助?請提供意見:

傳送您對下列選項的寶貴意見...

這個網頁