iT邦幫忙

2024 iThome 鐵人賽

DAY 26
0
Mobile Development

從零開始以Flutter打造跨平台聊天APP系列 第 26

Day-26 實作(7) 使用 Gin 架設登入註冊系統

  • 分享至 

  • xImage
  •  

Genrated by ChatGPT GPT-4o

今天開始要來建立後端伺服器,本次開發框架選用 gin+postgreSQL 來架設,首先我們先安裝 gin

範例程式碼:https://github.com/ksw2000/ironman-2024/tree/master/golang-server/whisper

Gin

go get -u github.com/gin-gonic/gin

由於我們的伺服器可能與前端的裝置不在同一個網域,因此我們需要在 router 中加上一層 middleware,這部分參考 https://stackoverflow.com/questions/29418478/go-gin-framework-cors

func CORSMiddleware() gin.HandlerFunc {
	return func(c *gin.Context) {
		c.Writer.Header().Set("Access-Control-Allow-Origin", "*")
		c.Writer.Header().Set("Access-Control-Allow-Credentials", "true")
		c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization, accept, origin, Cache-Control, X-Requested-With")
		c.Writer.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS, GET, PUT")

		if c.Request.Method == "OPTIONS" {
			c.AbortWithStatus(204)
			return
		}

		c.Next()
	}
}

接著我們依照 Day 25 所設計的 API 開始建構。我們先此「註冊」新用戶的邏輯開始下手:

func main() {
	router := gin.Default()
	// gin.SetMode(gin.ReleaseMode)
	router.Use(CORSMiddleware())

	router.POST("/api/v1/users", func(c *gin.Context) {
		// ...
	})

	router.Run(":8081")
}

根據我們的設計,需要取得相關的 JSON 輸入,我們將這些輸入包裝成 UserRequest 並將此檔案封裝在 users package 中,後面的 tag 中 binding:"required" 代表這些在 POST 時都必需給值。

package users

type UserRequest struct {
	Name                string `json:"name" binding:"required"`
	UserID              string `json:"user" binding:"required"`
	Password            string `json:"password" binding:"required"`
	Email               string `json:"email" binding:"required"`
	Pin                 string `json:"pin" binding:"required"`
	PublicKey           string `json:"public_key" binding:"required"`
	EncryptedPrivateKey string `json:"encrypted_private_key" binding:"required"`
}
.
└── whipser/
    ├── users/
    │   └── users.go
    ├── go.mod
    ├── go.sum
    └── main.go

回到 main.go 我們開始解悉 req,如果有值沒有給到,就回傳 bad request

router.POST("/api/v1/users", func(c *gin.Context) {
    req := users.UserRequest{}

    if err := c.ShouldBindJSON(&req); err != nil {
        c.JSON(http.StatusBadRequest, gin.H{"error": "bad request"})
        return
    }
    
    // TODO 處理 INPUT

    c.JSON(http.StatusOK, gin.H{"error": ""})
})

這裡我們直接示範一次,試試看完全沒有 input 的情況

> curl -X POST http://localhost:8081/api/v1/users -H "Content-Type: application/json" -d "{}"

{"error":"bad request"}

接著試試看完整的 input

> curl -X POST http://localhost:8081/api/v1/users -H "Content-Type: application/json" -d "{\"name\":\"日野下花帆\", \"user\": \"kaho_hinoshita\", \"password\": \"NozomiNirei0421\", \"email\": \"kahou@example.com\", \"pin\": \"20010421\", \"public_key\": \"test\", \"encrypted_private_key\": \"test\"}"

{"error":""}

pg

為了使用資料庫的功能,我們選用 pg 這個套件

go get -u go get github.com/go-pg/pg/v10

首先,先連結資料庫:

db := pg.Connect(&pg.Options{
    Addr:     "localhost:5432",
    User:     "postgres",
    Password: "example",
    Database: "whisper",
})
defer db.Close()

以上組態設定的部分,日後我們可以再額外抽出來以主要程式碼之外的 json 做設定。當我們處理 API 時,僅需將 db 與取得的資料送給函式做處理並取得相關回傳值再返回給 Client 即可。

註冊新用戶

註冊新用戶整個邏輯有點複雜,首先我們要在後端檢查各種格式:比如 email、帳號、密碼、PIN 碼。接著我們要產生鹽值,並且對密碼和 PIN 碼做 SHA-256 雜湊存於資料庫。

首先,我們先檢查各種格式,檢查 Email:

func checkEmail(email string) error {
	r := regexp.MustCompile(`^[a-z0-9._%+\-]+@[a-z0-9.\-]+\.[a-z]{2,}$`)
	if !r.MatchString(email) {
		return ErrorInvalidEmail
	}
	return nil
}

檢查帳號,我們這邊假設 ID 僅包含英文字母、下劃線及點 5~30 字元:

func checkUserID(userID string) error {
	r := regexp.MustCompile(`^[a-zA-Z0-9_.]{5,30}$`)
	if !r.MatchString(userID) {
		return ErrorInvalidUserID
	}
	return nil
}

檢查密碼,我們這邊假設密碼要包含英文大小寫及數字且介於 12 ~ 50 字元:

func checkPassword(password string) error {
	if len(password) < 12 && len(password) > 50 {
		return ErrorInvalidPassword
	}

	var hasUpper, hasLower, hasNumber bool
	for _, char := range password {
		switch {
		case unicode.IsUpper(char):
			hasUpper = true
		case unicode.IsLower(char):
			hasLower = true
		case unicode.IsNumber(char):
			hasNumber = true
		}
	}

	if !(hasUpper && hasLower && hasNumber) {
		return ErrorInvalidPassword
	}
	return nil
}

檢查 pin 碼,我們這邊假設 pin 碼為 6 ~ 20 位數字:

func checkPin(pin string) error {
	r := regexp.MustCompile(`^[0-9]{6,20}$`)
	if !r.MatchString(pin) {
		return ErrorInvalidPin
	}
	return nil
}

對應的 Error:

var (
	ErrorInvalidEmail    = errors.New("invalid email format")
	ErrorInvalidUserID   = errors.New("invalid userID format")
	ErrorInvalidPassword = errors.New("invalid password format")
	ErrorInvalidPin      = errors.New("invalid pin format")
	ErrorRepeatedUserID  = errors.New("id is already in use")
	ErrorRepeatedEmail   = errors.New("email is already in use")
)

除此之外,我們還要產生 salt,這個部分我們可以額外建立一個 util package

.
└── whipser/
    ├── users/
    │   └── users.go
    ├── utils/
    │   └── utils.go
    ├── go.mod
    ├── go.sum
    └── main.go
func GenerateSalt(size int) []byte {
	salt := make([]byte, size)
	_, err := io.ReadFull(rand.Reader, salt)
	if err != nil {
		panic(err)
	}
	return salt
}

func HashPasswordWithSalt(password, salt []byte) [32]byte {
	password = append(password, salt...)
	return sha256.Sum256(password)
}

並在 users 中利用 salt 將密碼與 pin 碼都做雜湊

salt := utils.GenerateSalt(32)
hashPassword := utils.HashPasswordWithSalt([]byte(u.Password), salt)
hashPin := utils.HashPasswordWithSalt([]byte(u.Pin), salt)

接著我們要檢查使用者帳號或 Email 是否有重複,為了避免檢查時有其他用戶同時新增,我們需要使用 Transaction 當沒有衝突發生時我們使用 tx.Commit() ,最後不管成功與否都直接呼叫 tx.Rollback() 即可,因為如果已經成功 commit 了,rollback 也不會退回前一步。

tx, err := db.Begin()
if err != nil {
    return fmt.Errorf("db.Begin failed: %w", err)
}
defer tx.Rollback()

if num, err := tx.Model((*User)(nil)).Where("user_id = ?", u.UserID).Count(); err != nil {
    log.Print(err)
    return fmt.Errorf("tx.Model.Where.Count(user_id) failed: %w", err)
} else if num > 0 {
    return ErrorRepeatedUserID
}

if num, err := tx.Model((*User)(nil)).Where("email = ?", u.Email).Count(); err != nil {
    log.Print(err)
    return fmt.Errorf("tx.Model.Where.Count(email) failed: %w", err)
} else if num > 0 {
    return ErrorRepeatedEmail
}

v := User{
    Name:                u.Name,
    UserID:              u.UserID,
    Email:               u.Email,
    PublicKey:           u.PublicKey,
    EncryptedPrivateKey: u.EncryptedPrivateKey,
    HashPassword:        base64.StdEncoding.EncodeToString(hashPassword[:]),
    HashPin:             base64.StdEncoding.EncodeToString(hashPin[:]),
    Salt:                base64.StdEncoding.EncodeToString(salt),
}

_, err = tx.Model(&v).Insert()
if err != nil {
    return fmt.Errorf("tx.Model.Insert failed: %w", err)
}

err = tx.Commit()
if err != nil {
    return fmt.Errorf("tx.Commit failed: %w", err)
}
return nil

users table 的 schema 如下:

create table users (
    id                      serial          not null primary key,
    name                    text            not null,
    user_id                 text            not null,
    email                   text            not null,
    profile                 text,
    public_key              text            not null,
    encrypted_private_key   text            not null,
    hash_password           char(44)        not null,
    hash_pin                char(44)        not null,
    salt                    char(44)        not null,
    created_at              timestamptz     default now(),
    updated_at              timestamptz     default now()
);

最後,我們將整個 Api 完成

router.POST("/api/v1/users", func(c *gin.Context) {
    req := users.UserRequest{}

    if err := c.ShouldBindJSON(&req); err != nil {
        c.JSON(http.StatusBadRequest, gin.H{"error": "bad request"})
        return
    }

    if err := req.Register(db); err != nil {
        switch err {
        case users.ErrorRepeatedUserID:
            c.JSON(http.StatusUnprocessableEntity, gin.H{
                "error": "ID 已被使用",
            })
        case users.ErrorRepeatedEmail:
            c.JSON(http.StatusUnprocessableEntity, gin.H{
                "error": "Email 已被使用",
            })
        case users.ErrorInvalidEmail:
            c.JSON(http.StatusBadRequest, gin.H{
                "error": "Email 格式不正確",
            })
        case users.ErrorInvalidUserID:
            c.JSON(http.StatusBadRequest, gin.H{
                "error": "ID 僅包含英文字母、下劃線及點 5~30 字元",
            })
        case users.ErrorInvalidPassword:
            c.JSON(http.StatusBadRequest, gin.H{
                "error": "密碼需包含大小寫英文字母及數字 12~50 字元",
            })
        case users.ErrorInvalidPin:
            c.JSON(http.StatusBadRequest, gin.H{
                "error": "Pin 碼為 6~20 位數字",
            })
        default:
            log.Println(err)
            c.JSON(http.StatusInternalServerError, gin.H{
                "error": "內部伺服器錯誤",
            })
        }
        return
    }

    c.JSON(http.StatusOK, gin.H{"error": ""})
})

接著我們在伺服器建立一個使用者,這裡以花帆的學姐乙宗梢來示範

curl -X POST http://localhost:8081/api/v1/users -H "Content-Type: application/json" -d "{\"name\": \"乙宗梢\",  \"user\": \"kozue\",  \"password\": \"Kozukozu0615\",  \"email\": \"kozu@example
.com\",  \"pin\": \"1020615\",  \"public_key\": \"example_pulic_key\",  \"encrypted_private_key\": \"example_encrypted_private_key\"}"

那一串 JSON:

{
  "name": "乙宗梢",
  "user": "kozue",
  "password": "Kozukozu0615",
  "email": "kozu@example.com",
  "pin": "1020615",
  "public_key": "example_pulic_key",
  "encrypted_private_key": "example_encrypted_private_key"
}

實作 Login API

Path: /api/v1/auth/login
Method: POST
Request:
	- user		string "使用者名稱"
	- password	string "密碼"
Response:
	success:
		- 200 OK
	fail:
		- 401 Unauthorized 驗證失敗
		- 500 Internal Server Error 伺服器端發生錯誤
	content:
		- token	string "權杖"
		- error	string

首先我們先建立一個 auth package

.
└── whipser/
    ├── auth/
    │   └── auth.go
    ├── users/
    │   └── users.go
    ├── utils/
    │   └── utils.go
    ├── go.mod
    ├── go.sum
    └── main.go
package auth

import "github.com/go-pg/pg/v10"

type LoginRequest struct {
	UserID   string `json:"user" binding:"required"`
	Password string `json:"password" binding:"required"`
}

func (l *LoginRequest) Login(db *pg.DB) (token string, err error) {
    // TODO
	return "", nil
}

接著在 router 中加入該 API

router.POST("/api/v1/auth/login", func(c *gin.Context) {
    req := auth.LoginRequest{}
    if err := c.ShouldBindJSON(&req); err != nil {
        c.JSON(http.StatusBadRequest, gin.H{"error": "bad request"})
        return
    }
    token, err := req.Login(db)
    if err != nil {
        c.JSON(http.StatusUnauthorized, gin.H{
            "token": "",
            "error": "驗證失敗",
        })
        return
    }

    c.JSON(http.StatusUnauthorized, gin.H{
        "token": token,
        "error": "",
    })
})

驗證使用者的方式要先向 users 表查尋是否有該 user

func (l *LoginRequest) Login(db *pg.DB) (token string, err error) {
	tx, err := db.Begin()
	if err != nil {
		return token, fmt.Errorf("db.Begin failed: %w", err)
	}
	defer tx.Rollback()
	res := users.User{}
	err = tx.Model(&res).Where("user_id = ?", l.UserID).Select()
	if err != nil {
		if err == pg.ErrNoRows {
			return token, ErrorAuthenticationFailed
		}
		return token, fmt.Errorf("tx.Model.Where.QueryOne failed: %w", err)
	}

	// 取得鹽值
	salt, err := base64.StdEncoding.DecodeString(res.Salt)
	if err != nil {
		return token, fmt.Errorf("base64.StdEncoding.DecodingString(res.Salt) failed: %w", err)
	}
	hashPassword := utils.HashPasswordWithSalt([]byte(l.Password), salt)

	// 取得資料庫中已雜湊的密碼
	hashPasswordInDB, err := base64.StdEncoding.DecodeString(res.HashPassword)
	if err != nil {
		return token, fmt.Errorf("base64.StdEncoding.DecodingString(res.HashPassword) failed: %w", err)
	}

	if string(hashPassword[:]) != string(hashPasswordInDB) {
		return token, ErrorAuthenticationFailed
	}

	// TODO

	return token, tx.Commit()
}

如果查不到帳號或密碼不對都要返回錯誤。

嘗試使用錯的帳號

{
  "user": "kozuee",
  "password": "Kozukozu0615"
}

嘗試使用錯的密碼

{
  "user": "kozuee",
  "password": "Kozukozu061"
}

此時都應該回傳 401 Unauthorized。

接著我們要在資料中生成另一個表格來分發權杖給登入的用戶。另外也可以擴展欄位在此記錄裝置或 ip,如此使用者可在不同裝置間登出。

create table auth (
    id          serial      not null primary key,
    uid         integer     references users (id) on delete cascade,
    token       char(64)    unique,
    created_at  timestamptz defualt now(),
    expired_at  timestamptz not null
);

對應的 struct,注意,在 pg 這個套件中會自動將 Auth 轉換成 auths 所以我們可以額外指定一個空的 tableName field 來表示提示將其轉為 auth。或者我們可以將原本的 table name 更換為 auths

type Auth struct {
	ID        int
	UID       int
	Token     string
	CreatedAt time.Time
	ExpiredAt time.Time
	tableName struct{} `pg:"auth"`
}

更換表格名稱的 SQL 語法

ALTER TABLE auth TO auths;

Github 中的範例程式碼為統一風格,會將 sql 中的 auth table 更換為 auths,並且 struct name 維持單數

最後我們完成 Login() 剩餘的部分

func (l *LoginRequest) Login(db *pg.DB) (token string, err error) {
    // 檢查帳號密碼
    // ...
    
	// 產生長度為 64 字元的 token
	token = base64.StdEncoding.EncodeToString(utils.GenerateSalt(48))
	auth := Auth{
		UID:       res.ID,
		Token:     token,
		ExpiredAt: time.Now().Add(time.Hour * 24 * 7),
	}

	if _, err = tx.Model(&auth).Insert(); err != nil {
		return "", fmt.Errorf("tx.Model.Insert failed: %w", err)
	}
	if err = tx.Commit(); err != nil {
		return "", fmt.Errorf("tx.Commit failed: %w", err)
	}

	return token, nil
}

嘗試登入:

> curl -X POST http://localhost:8081/api/v1/auth/login -H "Content-Type: application/json" -d "{\"user\": \"kozue\", \"password\": \"Kozukozu0615\"}"

{"error":"","token":"yB79qScvQqkj54f98lboVdJTZcnEZ6aGeXUWzAwku4AUlaFKt4N6YDCnh52OPtzX"}

當前端得到 Token 後可以將 Token 存入 SharedPreferences ,後續只要憑藉該 Token 就能進行身份驗證。

登出

Path: /api/v1/auth/logout
Method: POST
Header:
	- Authorization
Response:
	success:
		- 204 No Content
	fail:
		- 500 Internal Server Error 伺服器端發生錯誤
	content:
		- error	string

雖然對於一個聊天 APP 來說不太會需要登出,不過我們還是來實作一下吧!登出的邏輯,刪除 auth 表格中對應的 token 即使 token 不存在,我們也不需要在意,一律都回傳 204 表示登出成功。

router.POST("/api/v1/auth/logout", func(c *gin.Context) {
    token := c.GetHeader("Authorization")
    if token == "" {
        c.JSON(http.StatusNoContent, nil)
        return
    }
    err := auth.Logout(db, token)
    if err != nil {
        log.Println(err)
        c.JSON(http.StatusInternalServerError, gin.H{
            "error": "內部伺服器錯誤",
        })
        return
    }
    c.JSON(http.StatusNoContent, nil)
})
func Logout(db *pg.DB, token string) (err error) {
	_, err = db.Model((*Auth)(nil)).Where("token = ?", token).Delete()
	return err
}

一開始先看資料庫是否有該筆資料:

demo-select-token-in-auth

接著我們嘗試登出

> curl -X POST http://localhost:8081/api/v1/auth/logout -H "Authorization: yB79qScvQqkj54f98lboVdJTZcnEZ6aGeXUWzAwku4AUlaFKt4N6YDCnh52OPtzX"

登出成功後,再查看一次資料庫可以發現已經沒有資料了。


上一篇
Day-25 實作(6) 在 Docker 中使用 PostgreSQL 建立資料庫
下一篇
Day-27 實作(8) 使用 Gin 完成個人資料及朋友處理系統
系列文
從零開始以Flutter打造跨平台聊天APP30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言