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
หลังจากเห็นภาพรวม 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