Skip to main content

Implementing Authentication Logic

เนื่องจากเรามี 3 ส่วนที่ต้อง implement เดี่ยวจะเรื่มจากชั้นที่อยู่นอกที่สุดก่อน

ก่อนจะมา handle function ต่างๆส่วนมากผมจะทำ function utils ต่างๆเพื่อมาช่วยในเราการ dev ก่อนและ 1 function สำคัญที่ขาดไม่ได้เลยก็คือ bodyparser และเราจะไม่ไปลง lib ที่ไหนครับเพราะผมลองบางตัวแล้วไม่ถูกใจเนื่องจากผมอยากจะให้ตัว body นั้นถูกยัดเข้าไปใน type ของเราเลยนั้นเอง งั้นเรามาเขียนเองเลยดีกว่าครับ

ส่วนคนที่ไม่ทราบว่า bodyparser คืออะไร มาอ่านส่วนนี้กันก่อนครับ bodyparser คือ function ที่จะแปลง body ของ request ที่ client เรียกมาออกมาเป็น format ต่างๆไม่ว่าจะเป็น json หรืออื่นๆ

สร้างไฟล์ bodyparser.go ขี้นมาแล้วใส่โค้ดนี้ลงไปครับ

package utils

func Parse[T any](r *http.Request, body T) error {
	b, err := io.ReadAll(r.Body)
	if err != nil {
		return err
	}
	if err := json.Unmarshal([]byte(b), &body); err != nil {
		return err
	}
	return nil
}

function ที่เราจะทำนั้นผมทำให้เป็น generic เพื่อที่จะได้สามารถรับ type อะไรก็ได้แล้วเอา value มาใส่ให้ลงไปใน poiner ของตัวแปล อย่างที่เห็นคือผมรับ request (สิ่งที่เก็บ value ของ body) ของ net/http และตัวแปลของเราซึ่งเป็น generic type ต่อมาอ่าน body ออกมาเป็น byte array หรือ ในที่นี้คือ json ที่ถูก encode อยู่ และนำมา unmarshal เข้า pointer ของตัวแปล และหากไม่มี error ก็ให้ return ค่า nil กลับไป เท่านี้ก็จะได้ bodyparser ง่ายๆมาแล้วครับ

SignUp (POST request)

Overall Spec

Screenshot 2565-11-07 at 22.20.05.png

หลังจากเห็นภาพรวม service เราไปแล้วก่อนจะทำ function POST ใดๆผมจะทำ struct ของ request ไว้ก่อนครับเพื่อให้ง่ายต่อการรับ body เพราะเหตุผลที่เราสร้าง function bodyparser เพื่อให้เราสามารถสร้าง type ไว้รับ body ได้โดยง่ายนั้นเอง

สร้าง folder type และสร้างไฟล์ user.go ด้านใน

package types

type SignIn struct {
	Email    string `json:"email"`
	Password string `json:"password"`
}

การสร้าง struct เราควรจะใส่ชื่อ attribute เป็นรูปแบบ Capitalize เพราะ golang จะมองว่า attribute นั้นเป็น private และไม่สามารถดึงมาใช้ได้นั้นเอง ส่วนด้านหลังเอาไว้ unmarshal value เข้าไปนั้นเองดังน้ันเราควรจะตั้งให้เหมือนกับ request ที่จะได้รับ

Handler

func (h userHandler) SignUp(w http.ResponseWriter, r *http.Request) {
	if r.Method != "POST" {
		w.WriteHeader(http.StatusMethodNotAllowed)
		return
	}
	w.Header().Set("Content-Type", "application/json")
	var response []byte
	var body types.SignIn
	err := utils.Parse(r, &body)
	if err != nil {
		response_value := map[string]any{"success": false, "error": err.Error()}
		response, _ := json.Marshal(response_value)
		w.Write(response)
		return
	}

	token, base64, err := h.service.SignUp(body.Email, body.Password)
	if err != nil {
		response_value := map[string]any{"success": false, "error": err.Error()}
		response, _ := json.Marshal(response_value)
		w.Write(response)
		return
	}
	response, _ = json.Marshal(map[string]any{"success": true, "token": token, "image": base64})
	w.Write(response)
	return
}

ก่อนอื่นใน function นี้ผมตรวจ method ของ request นี้ก่อนและค่อย set content-type เป็น application/json ที่เราคุ้นหน้าคุ้นตาเพื่อให้ content ออกมาเป็นรูปแบบที่เราต้องการจริงๆ ต่อมา parse body โดยใช้ function ที่เราสร้างก่อนหน้านี้ และหากเกิด error ผมได้ handle ไว้และส่ง response ที่เราต้องการกลับไป และ return ด้วยเพราะไม่งั้นจะเกิด error เนื่องจากเรา write ไปแล้ว แล้วดันมีคำสั่ง write ใหม่

ลำดับต่อมาก็จะเป็นขั้นตอนการเรียก function ที่จะไปทำ business logic ของเรา และรับค่าที่ return ออกมาซึ่งใน spec ของเราคือเราจะส่ง token และรูปที่เป็น base64 กลับมานั้นเอง หลังจากทำ function สำเร็จและไม่มีข้อผิดพลาดเราก็จะปั้นตัว response ของเราและ write กล้บไปให้ user นั้นเอง

Service

ก่อนอื่น ลง library 2 ตัวก่อนซึ่งสำคัญกับการทำระบบ authentication ของเรานั้นคือ TOTP, และ JWT

$ go get github.com/pquerna/otp/totp

$ go get github.com/golang-jwt/jwt

func (s userService) SignUp(email string, password string) (*string, *string, error) {
	key, err := totp.Generate(totp.GenerateOpts{
		Issuer:      "GDSC KMUTT",
		AccountName: email,
	})
	if err != nil {
		return nil, nil, err
	}
	secret := key.Secret()
	hashedPwd, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
	if err != nil {
		return nil, nil, err
	}

	user, err := s.repository.CreateUser(email, string(hashedPwd), secret)
	if err != nil {
		return nil, nil, err
	}
	claims := jwt.MapClaims{
		"id":  user.Id,
		"exp": time.Now().Add(time.Hour * 72).Unix(),
	}

	// Create token
	token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
	tokenString, err := token.SignedString([]byte(config.C.JWT_SECRET))

	// Convert TOTP key into a PNG
	var buf bytes.Buffer
	img, err := key.Image(200, 200)
	if err != nil {
		return nil, nil, err
	}
	if err := png.Encode(&buf, img); err != nil {
		return nil, nil, err
	}
	base64string := "data:image/png;base64," + base64.StdEncoding.EncodeToString(buf.Bytes())
	return &tokenString, &base64string, nil
}

ส่วน service จะเยอะนิดนึงครับ เพราะเป็นส่วนที่รวม business logic ทั้งหมดที่จะประมวณผลข้อมูลที่เราได้มาออกมาเป็นสิ่งที่เราต้องการ

เริ่มจากพระเอกของเรา TOTP ที่เราจะมาลอง implement กัน ซึ่งหลักการของมันไม่ได้มีอะไรซับซ้อนครับ ก็คือเราจะ generate key ของ user คนปัจจุบันมาก่อน และเราก็จะได้ object key ซึ่งมีข้อมูลสำคัญที่เราต้องเก็บนั้นคือ secret ของ user นั้นเอง และ object key นี้สามารถดีงค่าอย่างอื่นออกมาได้ด้วย เช่น QRcode ที่เอาไว้สแกนโดยไม่ต้องใส่ secret โดยตรง

ดังนั้นใน function create user ผมเลยใส่ parameter secret เข้าไปด้วยเพื่อที่จะเอา secret ไปเก็บด้วยนั้นเอง คราวนี้หลังได้ key มาเราก็มาทำให้ password ของ user ปลอดภัยขึ้นด้วยการ hashed นั้นเอง ซึ่งจะทำให้ password กลายเป็น text ที่เราอ่านไม่ออก เพื่อที่จะให้คนที่ดูแล database ไม่สามารถดู password ของ user ได้นั้นเอง และหลังจากได้ข้อมูลทั้งหมดที่จะนำไปเก็บใน database เราก็เรียก function create user ผ่าน "Port" ที่เราสร้างได้เลย

หลังจากที่ได้ user ที่ return กลับมาเราก็นำมาสร้าง token JWT ได้เลยโดยกำหนดสื่งที่จะอยู่ใน token (ควรมี exp ไว้ตลอด เพราะเป็นอายุการใช้งานของ token) ในที่นี้เราใส่แค่ id ของ user ไปก่อน หลังจากได้ token ก็นำไปเข้า function signedstring ซึ่งจุดนี้เราจำเป็นต้องใช้ secret ของเราที่เราได้ set ไปแล้วใน config ดังนั้นเราก็เลยเรียกได้เลยเช่นเดียวกับตอนใส่ connection string ของ database ต่อมาเพื่อที่จะได้รูปมาเป็น format base64 เราไม่สามาถทำได้ทันทีเนื่องจาก library TOTP ไม่ได้ทำ method มาให้เราจึงต้องมาแปลงจาก img ที่เป็น type Image.image เอาเอง และวิธีคือเรานำ img เข้าไปใน function encode โดยที่จะเอา data เข้าไปเก็บใน bytes.buffer ทีนี้เราก็แค่นำ img ที่เป็น byte.buffer ไปแปลงเป็น base64 โดยใช้ function base64 encode ได้เลย (ที่ต้อง concat string เข้าไปด้านหน้าก่อนเพราะจำเป็นต้องบอกก่อนว่า type ของ data เป็น image นั้นเอง) เท่านี้เราก็ return ค่า token กับ base64 กลับไปได้เลย

Repository

func (u userRepositoryDB) CreateUser(email string, password string, secret string) (*User, error) {
	insert, err := u.db.Exec("INSERT INTO users (email, password, secret) VALUES (?, ?, ?)", email, password, secret)
	if err != nil {
		return nil, err
	}
	userId, err := insert.LastInsertId()
	var user = User{
		Id:       userId,
		Email:    email,
		Password: password,
		Secret:   secret,
	}

	return &user, nil
}

ส่วนนี้จะทำหน้าที่แค่อย่างเดียวคือการ insert data เข้า database ดังนั้นจุดที่สำคัญคือการเขียน query สำหรับ insert นั้นเอง และเนื่องจากใน spec เราเขียนไว้ว่าจะเอา user data ไปใช้ด้วยดังนั้นเราอย่าลืมปั่น model user ตามที่สร้างไว้ใน repository แล้วส่ง return กลับไปด้วย

เท่านี้การทำ service สำหรับ signup หรือ register ก็เสร็จสมบูรณ์แล้วแต่ยังขาดอีก service ที่สำคัญสำหรับการทำระบบ authentication ซึ่งมันก็คือระบบ signin นั้นเอง และเรามี surprise ครับเพราะ GDSC session ครั้งนี้จะให้ผู้ชมได้ลองทำเองด้วย และจะมีของรางวัลให้สำหรับผู้ที่ implement ได้ถูกต้อง 5 คนแรกด้วย แต่ขอสงวนสิทธิ์ให้คนที่เข้า session onsite เท่านั้นนะครับแน่นอนว่าเรามี documentation ให้ด้วยพร้อมทั้งถาม mentor ใน session ครั้งนี้ได้เต็มที่เลยครับขอให้โชคดีใน session หลังบ่ายนี้ที่จะเป็นการทำให้ project นี้น่าสนใจขึ้นไปอีก และถือเป็นการ meetup สำหรับผู้ที่สนใจการเขียน code เพื่อพัฒนา community coder ในมหาลัยนี้ไปด้วยครับ

TOTP: https://github.com/pquerna/otp

JWT: https://github.com/golang-jwt/jwt

bcrypt: https://pkg.go.dev/golang.org/x/crypto/bcrypt