基於 Go 語言開發在線論壇(四):通過 Cookie + Session 實現用戶認證

1、編寫全局輔助函數

在此之前,我們現在 handlers 目錄下創建一個 helper.go 文件,用於定義一些全局輔助函數(主要用在處理器中):

package handlers    import (      "errors"      "fmt"      "github.com/xueyuanjun/chitchat/models"      "html/template"      "net/http"  )    // 通過 Cookie 判斷用戶是否已登錄  func session(writer http.ResponseWriter, request *http.Request) (sess models.Session, err error) {      cookie, err := request.Cookie("_cookie")      if err == nil {          sess = models.Session{Uuid: cookie.Value}          if ok, _ := sess.Check(); !ok {              err = errors.New("Invalid session")          }      }      return  }    // 解析 HTML 模板(應對需要傳入多個模板文件的情況,避免重複編寫模板程式碼)  func parseTemplateFiles(filenames ...string) (t *template.Template) {      var files []string      t = template.New("layout")      for _, file := range filenames {          files = append(files, fmt.Sprintf("views/%s.html", file))      }      t = template.Must(t.ParseFiles(files...))      return  }    // 生成響應 HTML  func generateHTML(writer http.ResponseWriter, data interface{}, filenames ...string) {      var files []string      for _, file := range filenames {          files = append(files, fmt.Sprintf("views/%s.html", file))      }        templates := template.Must(template.ParseFiles(files...))      templates.ExecuteTemplate(writer, "layout", data)  }    // 返回版本號  func Version() string {      return "0.1"  }

目前提供了版本資訊,判斷用戶是否登錄,HTML 模板的解析與生成等邏輯,我們將 HTML 模板解析與生成邏輯提取出來,主要是為了避免重複編寫類似的模板程式碼,比如現在,我們可以將 handlers/index.go 中的 Index 方法改寫如下:

func Index(w http.ResponseWriter, r *http.Request) {      threads, err := models.Threads();      if err == nil {          generateHTML(w, threads, "layout", "navbar", "index")      }  }

是不是看起來簡單多了,更重要的是提高了程式碼的復用性。

session 函數中,通過從請求中獲取指定 Cookie 欄位裡面存放的 Session ID,然後從 Session 存儲器(這裡存儲驅動是資料庫)查詢對應 Session 是否存在來判斷用戶是否已認證,如果已認證則返回的 sess 不為空。

2、用戶認證相關處理器

1)編寫處理器程式碼

接下來,在 handlers 目錄下創建一個 auth.go 來存放用戶認證相關處理器:

package handlers    import (      "fmt"      "github.com/xueyuanjun/chitchat/models"      "net/http"  )    // GET /login  // 登錄頁面  func Login(writer http.ResponseWriter, request *http.Request) {      t := parseTemplateFiles("auth.layout", "navbar", "login")      t.Execute(writer, nil)  }    // GET /signup  // 註冊頁面  func Signup(writer http.ResponseWriter, request *http.Request) {      generateHTML(writer, nil, "auth.layout", "navbar", "signup")  }    // POST /signup  // 註冊新用戶  func SignupAccount(writer http.ResponseWriter, request *http.Request) {      err := request.ParseForm()      if err != nil {          fmt.Println("Cannot parse form")      }      user := models.User{          Name:     request.PostFormValue("name"),          Email:    request.PostFormValue("email"),          Password: request.PostFormValue("password"),      }      if err := user.Create(); err != nil {          fmt.Println("Cannot create user")      }      http.Redirect(writer, request, "/login", 302)  }    // POST /authenticate  // 通過郵箱和密碼欄位對用戶進行認證  func Authenticate(writer http.ResponseWriter, request *http.Request) {      err := request.ParseForm()      user, err := models.UserByEmail(request.PostFormValue("email"))      if err != nil {          fmt.Println("Cannot find user")      }      if user.Password == models.Encrypt(request.PostFormValue("password")) {          session, err := user.CreateSession()          if err != nil {              fmt.Println("Cannot create session")          }          cookie := http.Cookie{              Name:     "_cookie",              Value:    session.Uuid,              HttpOnly: true,          }          http.SetCookie(writer, &cookie)          http.Redirect(writer, request, "/", 302)      } else {          http.Redirect(writer, request, "/login", 302)      }  }    // GET /logout  // 用戶退出  func Logout(writer http.ResponseWriter, request *http.Request) {      cookie, err := request.Cookie("_cookie")      if err != http.ErrNoCookie {          fmt.Println("Failed to get cookie")          session := models.Session{Uuid: cookie.Value}          session.DeleteByUUID()      }      http.Redirect(writer, request, "/", 302)  }

上述程式碼中定義了用戶註冊、登錄、退出相關業務邏輯,非常簡單,和 Laravel 認證腳手架生成的默認認證相關控制器非常相似。

2)用戶註冊

用戶註冊邏輯比較簡單,無非是填寫註冊表單(Signup 處理器方法),提交註冊按鈕將用戶資訊保存到資料庫(SignupAccount 處理器方法)。

3)用戶登錄

接下來,服務端會將用戶重定向到登錄頁面(Login 處理器方法),用戶填寫登錄表單後,就可以通過 Authenticate 處理器方法執行認證操作。

用戶認證是基於 Cookie + Session 實現的,Session 的數據結構如下所示:

type Session struct {      Id        int      Uuid      string      Email     string      UserId    int      CreatedAt time.Time  }

通過 Uuid 欄位可以唯一標識這個 Session,因此可以看作是對外可見的全局 Session ID,在客戶端 Cookie 存儲的 Session ID 也是這個 Uuid。當用戶認證成功之後,就會創建 Session,有了 Session 之後,就可以創建 Cookie 並寫到響應中:

cookie := http.Cookie{      Name:     "_cookie",      Value:    session.Uuid,      HttpOnly: true,  }  http.SetCookie(writer, &cookie)

這樣,下次用戶訪問在線論壇頁面就會在請求頭中帶上包含 Session ID 的 Cookie,服務端通過解析這個 Uuid 並查詢 Session 存儲器(這裡存儲驅動是資料庫)判斷該用戶 Session 是否存在,如果存在則用戶認證通過,也就是前面輔助函數 session 所做的事情。

4)用戶退出

上述 Cookie 未設置過期時間,所以生命周期和 Session 一致,當瀏覽器關閉時,Cookie 就自動刪除,下次打開瀏覽器需要重新認證。

最後用戶退出處理器方法 Logout 方法則是方便用戶主動退出,當用戶點擊退出按鈕,可以執行該處理器方法銷毀當前用戶 Session 和認證 Cookie,並將用戶重定向到首頁。

3、用戶認證相關視圖模板

定義好認證處理器後,我們來編寫與認證相關的視圖模板,主要是登錄頁面和註冊頁面,在 views 目錄下新增 login.html 編寫登錄頁面:

{{ define "content" }}    <form class="form-signin center" role="form" action="/authenticate" method="post">    <h2 class="form-signin-heading">      <i class="fa fa-comments-o">        ChitChat      </i>    </h2>    <input type="email" name="email" class="form-control" placeholder="Email address" required autofocus>    <input type="password" name="password" class="form-control" placeholder="Password" required>    <br/>    <button class="btn btn-lg btn-primary btn-block" type="submit">Sign in</button>    <br/>    <a class="lead pull-right" href="/signup">Sign up</a>  </form>    {{ end }}

然後創建 signup.html 編寫註冊頁面:

{{ define "content" }}    <form class="form-signin" role="form" action="/signup_account" method="post">    <h2 class="form-signin-heading">      <i class="fa fa-comments-o">        ChitChat      </i>    </h2>    <div class="lead">Sign up for an account below</div>    <input id="name" type="text" name="name" class="form-control" placeholder="Name" required autofocus>    <input type="email" name="email" class="form-control" placeholder="Email address" required>    <input type="password" name="password" class="form-control" placeholder="Password" required>    <button class="btn btn-lg btn-primary btn-block" type="submit">Sign up</button>  </form>    {{ end }}

此外,我們還為登錄和註冊頁面定義了單獨的布局模板 auth.layout.html

{{ define "layout" }}    <!DOCTYPE html>  <html lang="en">    <head>      <meta charset="utf-8">      <meta http-equiv="X-UA-Compatible" content="IE=9">      <meta name="viewport" content="width=device-width, initial-scale=1">      <title>ChitChat</title>      <link href="/static/css/bootstrap.min.css" rel="stylesheet">      <link href="/static/css/font-awesome.min.css" rel="stylesheet">      <link href="/static/css/login.css" rel="stylesheet">    </head>    <body>      <div class="container">          {{ template "content" . }}        </div> <!-- /container -->        <script src="/static/js/jquery-2.1.1.min.js"></script>      <script src="/static/js/bootstrap.min.js"></script>    </body>  </html>    {{ end }}

以上視圖模板已經在認證處理器方法中引用。

4、註冊用戶認證路由

最後,我們需要在 routes/routes.go 中註冊用戶認證相關路由:

// 定義所有 Web 路由  var webRoutes = WebRoutes{      ... // 其他路由      {          "signup",          "GET",          "/signup",          handlers.Signup,      },      {          "signupAccount",          "POST",          "/signup_account",          handlers.SignupAccount,      },      {          "login",          "GET",          "/login",          handlers.Login,      },      {          "auth",          "POST",          "/authenticate",          handlers.Authenticate,      },      {          "logout",          "GET",          "/logout",          handlers.Logout,      },  }

5、測試用戶認證功能

這樣一來,我們就可以重啟應用並訪問用戶註冊頁面 http://localhost:8080/signup 進行註冊了:

註冊成功後,頁面會跳轉到登錄頁面 http://localhost:8080/login

輸入剛才填寫的註冊郵箱和密碼,點擊「SIGN IN」按鈕登錄成功後,頁面跳轉到首頁。

我們還沒有對首頁做額外的認證判斷和處理,所以此時顯示的頁面效果和之前一樣,為了區別用戶認證與未認證狀態,我們可以基於認證狀態渲染不同的導航模板,對於認證用戶,渲染 auth.navbar 模板,對於未認證用戶,還是保持和之前一樣,為此,我們需要在 views 目錄下新增 auth.navbar.html 視圖:

{{ define "navbar" }}  <div class="navbar navbar-default navbar-static-top" role="navigation">    <div class="container">      <div class="navbar-header">        <button type="button" class="navbar-toggle collapsed" data-toggle="collapse" data-target=".navbar-collapse">          <span class="sr-only">Toggle navigation</span>          <span class="icon-bar"></span>          <span class="icon-bar"></span>          <span class="icon-bar"></span>        </button>        <a class="navbar-brand" href="/">          <i class="fa fa-comments-o"></i>          ChitChat        </a>      </div>      <div class="navbar-collapse collapse">        <ul class="nav navbar-nav">          <li><a href="/">Home</a></li>        </ul>        <ul class="nav navbar-nav navbar-right">          <li><a href="/logout">Logout</a></li>        </ul>      </div>    </div>  </div>  {{ end }}

同時還要修改 handlers.Index 處理器方法實現:

func Index(writer http.ResponseWriter, request *http.Request) {      threads, err := models.Threads();      if err == nil {          _, err := session(writer, request)          if err != nil {              generateHTML(writer, threads, "layout", "navbar", "index")          } else {              generateHTML(writer, threads, "layout", "auth.navbar", "index")          }      }  }

再次重啟應用,刷新首頁,導航條的展示效果就不一樣了:

此時顯示的是「Logout」鏈接,點擊即可退出應用:

下篇教程我們將實現用戶介面的群組和主題增刪改查功能。