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 ง่าย ๆ มาแล้วครับ

ซึ่งในความจริงอาจจะมีมากกว่านี้เช่นเพิ่มการ validate เข้ามาด้วยไม่ใช่แค่นำข้อมูลใส่อย่างเดียว

SignUp (POST request)

Overall Spec

Screenshot 2565-11-19 at 00.32.18.png

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

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

package types

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

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

แต่ก่อนอื่นเรามาใช้วิธีง่าย ๆ ก่อนที่จะนำ process ทั้งหมดแปลงเป็น Hexagoal architecure ซึ่งจะค่อนข้างยาวเนื่องจากเราไม่มีการแยก function ไว้ครับ

ไม่จำเป็นต้องสนใจโค้ดมากนะครับ ดูส่วนที่ comment ของสิ่งที่ต้องทำก็พอครับ

	http.HandleFunc("/", (func(w http.ResponseWriter, r *http.Request) {
		// Check if the request method is POST
		if r.Method != "POST" {
			w.WriteHeader(http.StatusMethodNotAllowed)
			return
		}

		// Set the response header to application/json
		w.Header().Set("Content-Type", "application/json")

		// Declare a variable to store the body of the request
		var response []byte
		var body types.SignUp
		err := utils.Parse(r, &body)
		if err != nil {
			response, _ = json.Marshal(map[string]any{"success": false, "error": err.Error()})
			w.WriteHeader(http.StatusBadRequest)
			w.Write(response)
			return
		}

		// Generate a new secret TOTP key
		key, err := totp.Generate(totp.GenerateOpts{
			Issuer:      "GDSC KMUTT",
			AccountName: body.Email,
		})
		if err != nil {
			response, _ = json.Marshal(map[string]any{"success": false, "error": err.Error()})
			w.WriteHeader(http.StatusInternalServerError)
			w.Write(response)
			return
		}
		secret := key.Secret()

		// Hash the password
		hashedPwd, err := bcrypt.GenerateFromPassword([]byte(body.Password), bcrypt.DefaultCost)
		if err != nil {
			response, _ = json.Marshal(map[string]any{"success": false, "error": err.Error()})
			w.WriteHeader(http.StatusInternalServerError)
			w.Write(response)
			return
		}

		// Create a new user
		insert, err := db.Exec("INSERT INTO users (email, password, secret) VALUES (?, ?, ?)", body.Email, hashedPwd, secret)
		if err != nil {
			response, _ = json.Marshal(map[string]any{"success": false, "error": err.Error()})
			w.WriteHeader(http.StatusInternalServerError)
			w.Write(response)
			return
		}
		userId, err := insert.LastInsertId()
		if err != nil {
			response, _ = json.Marshal(map[string]any{"success": false, "error": err.Error()})
			w.WriteHeader(http.StatusInternalServerError)
			w.Write(response)
			return
		}

		// Convert TOTP key into a PNG, and encode it to base64
		var buf bytes.Buffer
		img, err := key.Image(200, 200)
		if err != nil {
			response, _ = json.Marshal(map[string]any{"success": false, "error": err.Error()})
			w.WriteHeader(http.StatusInternalServerError)
			w.Write(response)
			return
		}
		if err := png.Encode(&buf, img); err != nil {
			response, _ = json.Marshal(map[string]any{"success": false, "error": err.Error()})
			w.WriteHeader(http.StatusInternalServerError)
			w.Write(response)
			return
		}
		base64string := "data:image/png;base64," + base64.StdEncoding.EncodeToString(buf.Bytes())
		if err != nil {
			response, _ = json.Marshal(map[string]any{"success": false, "error": err.Error()})
			w.WriteHeader(http.StatusInternalServerError)
			w.Write(response)
			return
		}
		url := key.URL()

		// Create a response
		response, _ = json.Marshal(map[string]any{"success": true, "id": userId, "image": base64string, "secret": url})
		w.Write(response)
	}))

อย่างที่เห็นข้างต้น เราได้ทำทุก flow ภายใน function เดียว (ไม่มี function แยกให้เรียก) ทำให้ยากต่อการเทสและดูไม่เรียบร้อย โดยรวมของ Service ก็จะมีประมาณนี้ครับ แต่อย่างที่เห็นว่าการทำ Hexagonal architecture นั้นเสียเวลาค่อนข้างเยอะกว่าการที่นำทุกอย่างมารวมไว้ที่เดียว ดังนั้นจึงไม่จำเป็นว่าเราต้องทำ design pattern นี้เป็นหลัก แต่ควรพิจารณาจากสิ่งที่เรากำลังจะสร้าง (logic ขออธิบายใน section hexagonal เนื่องจากมีการจัดเป็นระเบียบจึงทำให้เข้าใจได้ง่ายกว่า)

Handler

func (h userHandler) SignUp(w http.ResponseWriter, r *http.Request) {
	// Check if the request method is POST
	if r.Method != "POST" {
		w.WriteHeader(http.StatusMethodNotAllowed)
		return
	}

	// Set the response header to application/json
	w.Header().Set("Content-Type", "application/json")
	var body types.SignIn
	err := utils.Parse(r, &body)
	var response []byte
	if err != nil {
		response, _ = json.Marshal(map[string]any{"success": false, "error": err.Error()})
		w.WriteHeader(http.StatusBadRequest)
		w.Write(response)
		return
	}

	// Call signup service
	id, base64, secret, err := h.service.SignUp(body.Email, body.Password)
	if err != nil {
		response, _ = json.Marshal(map[string]any{"success": false, "error": err.Error()})
		w.WriteHeader(http.StatusInternalServerError)
		w.Write(response)
		return
	}

	// Create a response
	response, _ = json.Marshal(map[string]any{"success": true, "id": id, "image": base64, "secret": secret})
	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 ของเราคือการส่ง secret และรูปที่เป็น base64 กลับมานั่นเอง หลังจากทำ function สำเร็จและไม่มีข้อผิดพลาด เราก็จะปั้นตัว response ของเราและ write กล้บไปให้ user นั่นเอง

Service

เริ่มจากการลง library 3 ตัวก่อน นั่นคือ TOTP, JWT, และ bcrypt ที่ซึ่งสำคัญกับการทำระบบ authentication

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

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

$ go get golang.org/x/crypto/bcrypt

func (s userService) SignUp(email string, password string) (*string, *string, error) {
	// Generate a new secret TOTP key
	key, err := totp.Generate(totp.GenerateOpts{
		Issuer:      "GDSC KMUTT",
		AccountName: email,
	})
	if err != nil {
		return nil, nil, err
	}
	secret := key.Secret()

	// Hash the password
	hashedPwd, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
	if err != nil {
		return nil, nil, err
	}

	// Create a new user
	user, err := s.repository.CreateUser(email, string(hashedPwd), secret)
	if err != nil {
		return nil, nil, err
	}

	// Create a new JWT claims
	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 กลับมาตาม process ที่เราออ็นำมาสร้าง token JWT ได้เลย โดยกำหนดสิ่งที่จะอยู่ใน token (ควรมี exp แบบไว้ตลอด เพื่อบ่งบอกอายุการใช้งานของ token) ในที่นี้เราใส่แค่จะนำ id ของ user ส่งไปก่อน หลังจากได้วย token(ความจริงไม่ควรนะครับ) ก็เพือนำไปเข้า functionprocess signedstringการ ซึconfirm otp ตงจุดนี้เราจำเป็นต้งใช้ 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 ของข้อมูลเป็น image นั่นเอง)

และเพื่อความง่ายเราก็สามารถส่ง URL ของ secret ไปด้วยเพื่อสร้าง QRcode ได้เช่นกันในที่นี้จะมีทั้ง 2 แบบเพื่อเป็นตัวอย่าง

เท่านี้เราก็ return ค่า tokenID, URL กับ base64 กลับไปได้เลย

Repository

func (u userRepositoryDB) CreateUser(email string, password string, secret string) (*User, error) {
	// Insert document into database
	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()

	// Create user object
	var user = User{
		Id:       userId,
		Email:    email,
		Password: password,
		Secret:   secret,
	}
	return &user, nil
}

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

เท่านี้การทำ Service สำหรับ signup หรือ register ก็เสร็จสมบูรณ์แล้ว แต่ยังขาดอีก service ที่สำคัญสำหรับการทำระบบ authentication ซึ่งก็คือระบบ signin

นอกจากนี้เรามีเซอร์ไพรส์ครับ เพราะ GDSC session ในครั้งนี้จะให้ผู้ชมได้ลองทำเองด้วย และยังมีของรางวัลให้สำหรับผู้ที่ implement ได้ถูกต้อง 5 คนแรก แต่ขอสงวนสิทธิ์ให้กับคนที่เข้า session onsite เท่านั้นนะครับ แน่นอนว่าเรามี documentation ให้ด้วยพร้อมทั้งสามารถถามเมนเทอร์ใน session ครั้งนี้ได้เต็มที่เลยครับ ขอให้โชคดีใน session หลังบ่ายนี้ที่จะเป็นการทำให้ project นี้น่าสนใจขึ้นไปอีก และถือเป็นการ meetup สำหรับผู้ที่สนใจการเขียนโค้ดเพื่อพัฒนา 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