ข้ามไปยังเนื้อหาหลัก

การยืนยันตัวตน (Authentication)

โดยทั่วไป การ Authentication ประกอบด้วยขั้นตอนต่อไปนี้:

  1. รับ Credential จากผู้ใช้ (เช่น Username/Password)
  2. ส่งไปให้ Backend
  3. เก็บ Token เพื่อใช้ในการ Request แบบระบุตัวตน (Authenticated requests)

รับ Credential จากผู้ใช้ยังไง

เราถือว่าแอปของคุณมีหน้าที่รับ Credential ถ้าคุณใช้ Authentication ผ่าน OAuth คุณก็แค่สร้างหน้า Login ที่มีลิงก์ไปยังหน้า Login ของผู้ให้บริการ OAuth แล้วข้ามไป ขั้นตอนที่ 3 ได้เลย

หน้า Login แบบแยกต่างหาก

ปกติแล้ว เว็บไซต์มักจะมีหน้าสำหรับ Login แยกต่างหาก เพื่อให้กรอก Username และ Password หน้าพวกนี้มักจะเรียบง่าย ไม่ต้องการการ Decomposition อะไรมาก แบบฟอร์ม Login และ Registration มักจะหน้าตาคล้ายๆ กัน เลยอาจจะรวมอยู่ในหน้าเดียวกันได้ สร้าง Slice สำหรับหน้า Login/Registration ของคุณใน Pages layer:

  • 📂 pages
    • 📂 login
      • 📂 ui
        • 📄 LoginPage.tsx (หรือไฟล์ Component ตาม Framework ของคุณ)
        • 📄 RegisterPage.tsx
      • 📄 index.ts
    • หน้าอื่นๆ...

ที่นี่เราสร้าง 2 Components และ Export ทั้งคู่ออกไปในไฟล์ index ของ Slice Components เหล่านี้จะมี Form ที่มีหน้าที่นำเสนอ Control ที่เข้าใจง่ายให้ผู้ใช้กรอก Credential

Dialog สำหรับ Login

ถ้าแอปของคุณมี Dialog สำหรับ Login ที่ใช้ได้ในทุกหน้า ลองพิจารณาสร้าง Dialog นั้นเป็น Widget ดูสิ วิธีนี้ช่วยให้คุณไม่ต้อง Decompose มากเกินไป แต่ยังมีอิสระที่จะนำ Dialog นี้ไปใช้ซ้ำในหน้าไหนก็ได้

  • 📂 widgets
    • 📂 login-dialog
      • 📂 ui
        • 📄 LoginDialog.tsx
      • 📄 index.ts
    • Widgets อื่นๆ...

ส่วนที่เหลือของไกด์นี้เขียนสำหรับแนวทางแบบหน้าแยกต่างหาก แต่หลักการเดียวกันก็ใช้กับ Dialog widget ได้นะ

Client-side validation

บางครั้ง โดยเฉพาะตอนสมัครสมาชิก (Registration) มันสมเหตุสมผลที่จะตรวจสอบข้อมูลฝั่ง Client เพื่อให้ผู้ใช้รู้ทันทีว่าทำอะไรผิด การ Validation สามารถทำได้ใน model segment ของหน้า Login ใช้ Library สำหรับ Schema validation เช่น Zod สำหรับ JS/TS และ Expose schema นั้นให้ ui segment ใช้:

pages/login/model/registration-schema.ts
import { z } from "zod";

export const registrationData = z.object({
email: z.string().email(),
password: z.string().min(6),
confirmPassword: z.string(),
}).refine((data) => data.password === data.confirmPassword, {
message: "รหัสผ่านไม่ตรงกัน",
path: ["confirmPassword"],
});

จากนั้น ใน ui segment คุณสามารถใช้ Schema นี้เพื่อ Validate ข้อมูลที่ผู้ใช้กรอก:

pages/login/ui/RegisterPage.tsx
import { registrationData } from "../model/registration-schema";

function validate(formData: FormData) {
const data = Object.fromEntries(formData.entries());
try {
registrationData.parse(data);
} catch (error) {
// TODO: แสดง Error message ให้ผู้ใช้เห็น
}
}

export function RegisterPage() {
return (
<form onSubmit={(e) => validate(new FormData(e.target))}>
<label htmlFor="email">อีเมล</label>
<input id="email" name="email" required />

<label htmlFor="password">รหัสผ่าน (ขั้นต่ำ 6 ตัวอักษร)</label>
<input id="password" name="password" type="password" required />

<label htmlFor="confirmPassword">ยืนยันรหัสผ่าน</label>
<input id="confirmPassword" name="confirmPassword" type="password" required />
</form>
)
}

ส่ง Credential ไป Backend ยังไง

สร้างฟังก์ชันที่ส่ง Request ไปยัง Login endpoint ของ Backend ฟังก์ชันนี้อาจถูกเรียกโดยตรงใน Component code โดยใช้ Mutation library (เช่น TanStack Query) หรือเรียกเป็น Side effect ใน State manager ก็ได้ อย่างที่อธิบายไปใน ไกด์สำหรับ API requests คุณสามารถวาง Request ของคุณไว้ใน shared/api หรือใน api segment ของหน้า Login ก็ได้

Two-factor authentication (2FA)

ถ้าแอปของคุณรองรับ 2FA คุณอาจต้อง Redirect ไปยังอีกหน้าเพื่อให้ผู้ใช้กรอก One-time password ปกติแล้ว POST /login request ของคุณจะคืนค่า User object พร้อม Flag ที่บอกว่าผู้ใช้เปิด 2FA ไว้หรือไม่ ถ้า Flag นี้ถูกเซ็ต ก็ Redirect ผู้ใช้ไปหน้า 2FA ซะ

เนื่องจากหน้านี้เกี่ยวข้องกับการ Login มากๆ คุณสามารถเก็บมันไว้ใน Slice เดียวกัน คือ login ใน Pages layer ได้เลย

คุณต้องมี Request function อีกอันที่คล้ายกับ login() ที่เราสร้างก่อนหน้านี้ วางไว้ด้วยกันเลย ไม่ว่าจะใน Shared หรือใน api segment ของหน้า login

เก็บ Token สำหรับ Authenticated requests ยังไง

ไม่ว่าคุณจะใช้แบบไหน จะ Login ง่ายๆ ด้วย Password, OAuth, หรือ 2FA สุดท้ายคุณก็จะได้ Token มา Token นี้ควรถูกเก็บไว้เพื่อให้ Request ต่อๆ ไปสามารถระบุตัวตนได้

ที่เก็บ Token ในอุดมคติสำหรับเว็บแอปคือ Cookie — ซึ่งไม่ต้องจัดการหรือเก็บ Token เองเลย ดังนั้น Cookie storage แทบไม่ต้องคำนึงถึงในฝั่งสถาปัตยกรรม Frontend ถ้า Frontend framework ของคุณมี Server side (เช่น Remix) คุณควรเก็บ Server-side cookie infrastructure ไว้ใน shared/api มีตัวอย่างใน ส่วน Authentication ของ Tutorial ว่าทำยังไงใน Remix

อย่างไรก็ตาม บางครั้ง Cookie storage ก็ไม่ใช่ทางเลือก ในกรณีนี้ คุณต้องเก็บ Token เอง นอกจากการเก็บ Token แล้ว คุณอาจต้องจัดการ Logic สำหรับการ Refresh token เมื่อมันหมดอายุด้วย ใน FSD มีหลายที่ที่คุณสามารถเก็บ Token ได้ และหลายวิธีที่จะทำให้ส่วนอื่นๆ ของแอปเรียกใช้ได้

ใน Shared

วิธีนี้เข้ากันได้ดีกับ API client ที่นิยามไว้ใน shared/api เพราะ Token จะพร้อมใช้งานสำหรับ Request functions อื่นๆ ที่ต้องการ Authentication คุณสามารถทำให้ API client เก็บ State ได้ ไม่ว่าจะด้วย Reactive store หรือตัวแปรระดับ Module ง่ายๆ และอัปเดต State นั้นในฟังก์ชัน login()/logout() ของคุณ

Automatic token refresh สามารถทำเป็น Middleware ใน API client — ซึ่งจะทำงานทุกครั้งที่คุณส่ง Request มันทำงานแบบนี้:

  • Authenticate และเก็บ Access token และ Refresh token
  • ส่ง Request ใดๆ ที่ต้องการ Authentication
  • ถ้า Request ล้มเหลวด้วย Status code ที่บอกว่า Token หมดอายุ และมี Token ใน Store ให้ส่ง Refresh request, เก็บ Token ใหม่, และลองส่ง Request เดิมอีกครั้ง

ข้อเสียอย่างนึงของวิธีนี้คือ Logic การจัดการและ Refresh token ไม่มีที่อยู่เฉพาะเจาะจง อาจจะโอเคสำหรับบางแอปหรือบางทีม แต่ถ้า Logic การจัดการ Token ซับซ้อนขึ้น อาจจะดีกว่าถ้าแยกความรับผิดชอบของการส่ง Request และการจัดการ Token ออกจากกัน คุณทำได้โดยเก็บ Request และ API client ไว้ใน shared/api แต่เอา Token store และ Management logic ไปไว้ใน shared/auth

ข้อเสียอีกอย่างคือ ถ้า Backend ส่ง Object ข้อมูลผู้ใช้ปัจจุบันมาพร้อมกับ Token คุณต้องหาที่เก็บมัน หรือไม่งั้นก็ต้องทิ้งข้อมูลนั้นไป แล้ว Request ใหม่จาก Endpoint เช่น /me หรือ /users/current

ใน Entities

เป็นเรื่องปกติสำหรับโปรเจกต์ FSD ที่จะมี Entity สำหรับ User และ/หรือ Entity สำหรับ Current user มันอาจจะเป็น Entity เดียวกันเลยก็ได้

note

Current user บางครั้งเรียกว่า "viewer" หรือ "me" เพื่อแยก User ที่ Login แล้วซึ่งมี Permission และข้อมูลส่วนตัว ออกจากรายชื่อ Users ทั้งหมดที่มีข้อมูลสาธารณะ

ในการเก็บ Token ใน User entity ให้สร้าง Reactive store ใน model segment Store นั้นสามารถเก็บทั้ง Token และ User object

เนื่องจากปกติ API client จะอยู่ใน shared/api หรือกระจายอยู่ใน Entities ความท้าทายหลักของวิธีนี้คือการทำให้ Token พร้อมใช้งานสำหรับ Request อื่นๆ ที่ต้องการมัน โดยไม่ละเมิด กฎการ Import ของ Layers:

Module (ไฟล์) ใน Slice หนึ่ง สามารถ Import Slice อื่นๆ ได้เฉพาะเมื่อ Slice เหล่านั้นอยู่ใน Layer ที่ต่ำกว่าอย่างเคร่งครัด

มีความท้าทายหลายอย่าง:

  1. ส่ง Token เองทุกครั้งที่ส่ง Request
    นี่เป็นวิธีที่ง่ายที่สุด แต่ก็จะเริ่มยุ่งยากและน่ารำคาญอย่างรวดเร็ว และถ้าคุณไม่มี Type safety ก็ลืมง่ายด้วย แถมยังไม่เข้ากับ Pattern ของ Middleware สำหรับ API client ใน Shared อีกต่างหาก
  2. Expose Token ให้ทั้งแอปผ่าน Context หรือ Global store เช่น localStorage
    Key สำหรับดึง Token จะถูกเก็บไว้ใน shared/api เพื่อให้ API client เข้าถึงได้ Reactive store ของ Token จะถูก Export จาก User entity และ Context provider (ถ้าจำเป็น) จะถูกตั้งค่าใน App layer วิธีนี้ให้อิสระในการออกแบบ API client มากกว่า แต่สร้าง Dependency แฝงไปยัง Layer ที่สูงกว่าเพื่อจัดเตรียม Context เมื่อใช้วิธีนี้ ควรเตรียม Error message ที่ช่วยบอกปัญหาถ้า Context หรือ localStorage ไม่ได้ถูกตั้งค่าอย่างถูกต้อง
  3. Inject Token เข้าไปใน API client ทุกครั้งที่มันเปลี่ยน
    ถ้า Store ของคุณ Reactive คุณสามารถสร้าง Subscription ที่จะอัปเดต Token store ของ API client ทุกครั้งที่ Store ใน Entity เปลี่ยน วิธีนี้คล้ายกับวิธีก่อนหน้าตรงที่สร้าง Dependency แฝงไปยัง Layer ที่สูงกว่า แต่วิธีนี้เป็นแบบ Imperative ("push") มากกว่า ในขณะที่วิธีก่อนหน้าเป็นแบบ Declarative ("pull")

เมื่อคุณจัดการเรื่องการ Expose token ที่เก็บใน Entity's model ได้แล้ว คุณสามารถใส่ Business logic ที่ซับซ้อนขึ้นเกี่ยวกับการจัดการ Token ลงไปได้ เช่น model segment สามารถมี Logic เพื่อ Invalidate token หลังจากช่วงเวลาหนึ่ง หรือ Refresh token เมื่อมันหมดอายุ สำหรับการส่ง Request จริงๆ ไปยัง Backend ให้ใช้ api segment ของ User entity หรือ shared/api

ใน Pages/Widgets (ไม่แนะนำ)

ไม่แนะนำให้เก็บ App-wide state เช่น Access token ใน Pages หรือ Widgets หลีกเลี่ยงการวาง Token store ใน model segment ของหน้า Login ให้เลือกวิธีจากสองข้อแรกแทน คือ Shared หรือ Entities

Logout และ Token invalidation

ปกติ แอปมักจะไม่มีหน้าสำหรับ Logout ทั้งหน้า แต่ฟังก์ชัน Logout ก็ยังสำคัญมาก มันประกอบด้วย Authenticated request ไปยัง Backend และการอัปเดต Token store

ถ้าคุณเก็บ Requests ทั้งหมดไว้ใน shared/api ให้เก็บ Logout request function ไว้ที่นั่น ใกล้ๆ กับ Login function แต่ถ้าไม่ใช่ ให้พิจารณาเก็บ Logout request function ไว้ใกล้ๆ ปุ่มที่กด Logout เช่น ถ้าคุณมี Header widget ที่ปรากฏทุกหน้าและมีลิงก์ Logout ให้วาง Request นั้นใน api segment ของ Widget นั้น

การอัปเดต Token store จะต้องถูกเรียกจากจุดที่มีปุ่ม Logout เช่น Header widget คุณสามารถรวม Request และ Store update ไว้ใน model segment ของ Widget นั้นได้

Automatic logout

อย่าลืมสร้างระบบกันเหนียว (Failsafes) สำหรับตอนที่ Request เพื่อ Logout ล้มเหลว หรือ Request เพื่อ Refresh token ล้มเหลว ในทั้งสองกรณีนี้ คุณควรเคลียร์ Token store ทิ้ง ถ้าคุณเก็บ Token ใน Entities โค้ดส่วนนี้วางใน model segment ได้เลยเพราะเป็น Pure business logic ถ้าคุณเก็บ Token ใน Shared การวาง Logic นี้ใน shared/api อาจทำให้ Segment บวมและผิดวัตถุประสงค์ ถ้าคุณเริ่มสังเกตว่า API segment ของคุณมีของสองอย่างที่ไม่เกี่ยวกันอยู่ด้วยกัน ลองแยก Logic การจัดการ Token ออกไปอีก Segment นึงดูสิ เช่น shared/auth