前言

最近又要開始接觸到網頁的東西了! 久沒有碰要再花一點時間回想。想說就趁著這次順手記錄起來吧。

主要內容

寫在前面

本篇我們會透過 gin 框架建立 API 伺服器。一開始會先建立一個測試用的 API,接著在試著提供網頁服務後;我們會開始建立登入功能所需的 API login 以及 auth

  • login 會檢查登入所使用的帳戶密碼,在驗證正確後會給予 Token 於 Cookie 中。
  • auth 會檢查 Cookie 是否有合法的 Token 。

由於本篇主要是專注在網頁後端「如何使用 gin 框架」,所以有關 HTML、 JavaScript 、CSS 並不會著墨太多。

建立專案

1go mod init github.com/e61983/test-gin-login
2go get -u github.com/gin-gonic/gin

起手式

建立測試用的 API - ping

 1cat << EOF > main.go
 2package main
 3
 4import (
 5	"net/http"
 6
 7	"github.com/gin-gonic/gin"
 8)
 9
10func main() {
11	r := gin.Default()
12	r.GET("/ping", func(c *gin.Context) {
13		c.JSON(http.StatusOK, gin.H{
14			"message": "pong",
15		})
16	})
17	r.Run()
18}
19EOF

現在讓我們先跑起來看看!

1go run main.go

並在另一個終端機上試著存取我們建立的 API ping

1curl -X GET http://localhost:8080/ping
2
3# Output:
4{"message":"pong"}%

提供網頁服務

簡單的建立測試用的頁面。

 1mkdir -p webroot
 2cat << EOF > webroot/index.html
 3<!DOCTYPE html>
 4<html lang="en">
 5<head>
 6    <meta charset="UTF-8">
 7    <meta http-equiv="X-UA-Compatible" content="IE=edge">
 8    <meta name="viewport" content="width=device-width, initial-scale=1.0">
 9    <title>Home Page</title>
10</head>
11<body>
12    <h1>Home Page</h1>
13</body>
14</html>
15EOF

接下來透過提供 LoadHTMLGlob() 載入文件夾中的靜態頁面。

1r.LoadHTMLGlob("webroot/*")
2r.GET("/", func(c *gin.Context) {
3	c.HTML(http.StatusOK, "index.html", gin.H{})
4})

重新運行後,使用瀏覽器觀看執行結果

我們的第一個畫面

我們的第一個畫面

登入 ( Login ) API

在使用 gin 的綁定 (binding) 功能時,我們要在想要綁定的欄位後面,依照想要綁定的方法加上 Struct Tags。 之我們就可以使用 gin.Bind() 或是 gin.ShouldBind() 來取得網頁前端所傳輸的資料。 本篇是用直接比對帳號密碼的方式實作,同學在實務上不要這樣學喔! 在檢驗完之後,將 Token 記錄於 Cookie 。 不過,目前常見驗證是採 JWT 的驗證方式,所以同學也自己來試著改寫看看。

 1type User struct {
 2	Account  string `json:"account" form:"account"`
 3	Password string `json:"password" form:"password"`
 4}
 5
 6r.POST("/login", func(c *gin.Context) {
 7	/* 綁定資料 */
 8	u := &User{}
 9	if c.ShouldBind(&u) != nil {
10		c.JSON(http.StatusOK, gin.H{"err": 1})
11		return
12	}
13	/* 檢查帳號密碼 */
14	if u.Account != TEST_ACCOUNT || u.Password != TEST_PASSWORD {
15		c.JSON(http.StatusOK, gin.H{"err": 2})
16		return
17	}
18	/* 將 Token 記錄於 Cookie 中 */
19	c.SetCookie(TOKEN_KEY, TEST_TOKEN, 3600, "/", HOST, false, false)
20	c.JSON(http.StatusOK, gin.H{})
21})

驗證 ( Auth ) API

我們可以從瀏覽器發送的請求中取出我們先前記錄於 Cookie 的 Token 。 並且比對它是否合法。

 1r.POST("/auth", func(c *gin.Context) {
 2	/* 從 Cookie 取出 Token */
 3	if token, err := c.Cookie(TOKEN_KEY); err != nil {
 4		if TEST_TOKEN != token {
 5			c.JSON(http.StatusOK, gin.H{"err": 2})
 6			return
 7		}
 8	}
 9	c.JSON(http.StatusOK, gin.H{"err": nil})
10})

建立登入表單與登入後顯示畫面

建立登入頁面。

 1cat << EOF > webroot/login.html
 2<!DOCTYPE html>
 3<html lang="en">
 4<head>
 5    <meta charset="UTF-8">
 6    <meta http-equiv="X-UA-Compatible" content="IE=edge">
 7    <meta name="viewport" content="width=device-width, initial-scale=1.0">
 8    <link rel="stylesheet" href="assets/core.css">
 9    <title>Login Page</title>
10</head>
11<body>
12    <h1>Login Page</h1>
13    <div class="container">
14        <form>
15            <div class="input-wrapper">
16                <div class="title">Login</div>
17                <div class="label" for="account">Account:</div>
18                <input type="text" id="account" name="account" value="tester" />
19                <div class="label" for="password">Password:</div>
20                <input type="password" id="password" name="password" value="test123" />
21                <button id="btn" type="button"> Submit </button>
22            </div>
23        </form>
24    </div>
25    <script type="module" >
26        import {getData, postData, getCookie, deleteCookie} from '/javascript/core.js';
27        function login() {
28            let account = document.getElementById("account").value;
29            let password = document.getElementById("password").value;
30            postData("http://localhost:8080/login",
31                {"account": account, "password": password})
32                .then(data=>{
33                    console.log(data.err)
34                    if (data.err !== null){
35                        window.location.replace("/")
36                    }else{
37                        window.location.replace("/admin/")
38                    }
39                });
40        }
41        function check(token) {
42            postData("http://localhost:8080/auth", {"token": token})
43                .then(data=>{
44                    if (data.err === null){
45                        window.location.replace("/admin/")
46                    }
47                });
48        }
49        check(getCookie("token"))
50        window.onload = function(){
51            document.getElementById("btn").addEventListener("click", login);
52        }
53    </script>
54</body>
55</html>
56EOF

改寫歡迎畫面

 1cat << EOF > webroot/index.html
 2<!DOCTYPE html>
 3<html lang="en">
 4<head>
 5    <meta charset="UTF-8">
 6    <meta http-equiv="X-UA-Compatible" content="IE=edge">
 7    <meta name="viewport" content="width=device-width, initial-scale=1.0">
 8    <link rel="stylesheet" href="/assets/core.css">
 9    <title>Home Page</title>
10</head>
11<body>
12    <h1>Home Page</h1>
13    <div class="container">
14        <div class="input-wrapper">
15            <div class="title">Hello word</div>
16            <button id="btn" type="button"> Logout </button>
17        </div>
18    </div>
19    <script type="module" >
20        import {getData, postData, getCookie, deleteCookie} from '/javascript/core.js';
21
22        function logout() {
23            deleteCookie("token", "/", "localhost")
24            window.location.replace("/")
25        }
26        window.onload = function(){
27            document.getElementById("btn").addEventListener('click', logout);
28        }
29    </script>
30</body>
31</html>
32EOF

畫存取清界線

我們前面建立了將要顯示的頁面,現在我們要來設定存取的路徑。

 1/* 登入頁面 */
 2r.GET("/", func(c *gin.Context) {
 3	c.HTML(http.StatusOK, "login.html", nil)
 4})
 5
 6/* 需要登入才能存取的頁面 */
 7admin := r.Group("/admin")
 8admin.GET("/", func(c *gin.Context) {
 9	if currentUser, ok := c.Get("User"); ok {
10		log.Printf("User [ %s ] Accessed", currentUser)
11	}
12	c.HTML(http.StatusOK, "index.html", nil)
13})

建立中間層

雖然我們已經建立了 /login/admin/ 這 2 個路徑,但如果我們直接在劉覽器中輸入 http://localhost:8080/admin/。會發現還是可以存取的。 為了避免這樣的事情發生,我們可以建立中間層來檢查是否已登入。 如果發現尚未登入的存取請求,我們就將其轉至登入畫面。

 1func AuthMiddleware() gin.HandlerFunc {
 2	return func(c *gin.Context) {
 3		cookieToken, err := c.Cookie(TOKEN_KEY)
 4		if err != nil && cookieToken != TEST_TOKEN {
 5			c.Redirect(http.StatusTemporaryRedirect, BASEURL)
 6			c.AbortWithStatus(http.StatusTemporaryRedirect)
 7			return
 8		} else {
 9			c.Set("User", TEST_ACCOUNT)
10			c.Next()
11		}
12	}
13}

使用中間層

在我們建立好的 admin 路由群組中,使用我們建立好的中間層。如此一來,要存取此路由群組的請求就會檢查是否已登入了。

1admin.Use(AuthMiddleware())

登入表單

登入表單

登入後歡迎畫面

登入後歡迎畫面

小結

本篇省略了很多實作上的細節,對被省略的部份有興趣的同學可以參考這裡

參考連結