บทที่ 17advanced|16 KB
บทที่ 17: Complete Authentication Flow
สารบัญเนื้อหา
บทที่ 17: Complete Authentication Flow
🎯 เป้าหมายการเรียนรู้
หลังจากเรียนบทนี้แล้ว คุณจะสามารถ:
- เข้าใจกระบวนการ Authentication แบบเจาะลึก (Frontend - Backend)
- สร้างระบบ Login/Register แบบ Secure
- จัดการ Token และ Session ในฝั่งมือถือ (Flutter)
- สร้างและใช้งาน Protected Routes
- เข้าใจระบบ Auto-login และ Logout
📚 ทฤษฎีและแนวคิด
ภาพรวมระบบ Authentication (Auth Flow)
ระบบ Auth ของ QUOM ใช้มาตรฐาน JWT (JSON Web Token) ซึ่งมีความปลอดภัยและเป็นที่นิยมสูงสุด โดยมีขั้นตอนการทำงานดังนี้:
sequenceDiagram
participant User
participant App (Flutter)
participant API (Node.js)
participant DB (MongoDB)
User->>App: 1. กรอก Email/Password
App->>API: 2. ส่ง POST /api/auth/login
API->>DB: 3. ค้นหา User
DB-->>API: พบ User (ได้ Password Hash)
API->>API: 4. ตรวจสอบรหัสผ่าน (bcrypt)
alt รหัสผ่านถูกต้อง
API->>API: 5. สร้าง JWT Token
API-->>App: 6. ส่ง Token กลับไป { token, user }
App->>App: 7. บันทึก Token ลงเครื่อง (Session)
App-->>User: 8. เข้าสู่หน้า Home
else รหัสผ่านผิด
API-->>App: Error (401 Unauthorized)
App-->>User: แสดงแจ้งเตือน
end
องค์ประกอบสำคัญ
-
Backend (บริการ):
- Register: รับข้อมูล -> ตรวจสอบ -> Hash รหัสผ่าน -> บันทึกลงฐานข้อมูล
- Login: รับข้อมูล -> ตรวจสอบ -> สร้าง Token แจกให้
- Middleware: ด่านตรวจคนเข้าเมือง คอยเช็ค Token ในทุก Request
-
Frontend (แอปพลิเคชัน):
- Session Manager: ตัวจัดการเก็บ/ลบ Token ในเครื่อง
- Interceptor: ตัวแอบแนบ Token ไปกับทุก Request โดยอัตโนมัติ
- Auto Login: เปิดแอปมาไม่ต้อง Login ใหม่ (ถ้า Token ยังไม่หมดอายุ)
💻 การพัฒนาฝั่ง Backend (Node.js)
1. User Model (โครงสร้างข้อมูลผู้ใช้)
ไฟล์: backend/src/models/User.js
เราต้องออกแบบฐานข้อมูลให้รองรับการเก็บรหัสผ่านอย่างปลอดภัย
/* backend/src/models/User.js */
import mongoose from "mongoose";
**อธิบายทีละบรรทัด (Imports):**
- `import mongoose from "mongoose"`
- Library สำหรับเชื่อมต่อ MongoDB และสร้าง Schema Model
**อธิบาย:**
const UserSchema = new mongoose.Schema(
{
email: {
type: String,
unique: true, // สำคัญ: ห้ามอีเมลซ้ำ
required: true,
index: true // ช่วยให้ค้นหาเร็วขึ้น
},
passwordHash: {
type: String,
required: true // เราไม่เก็บ password ดิบ แต่เก็บ Hash
},
displayName: {
type: String,
default: ""
},
role: {
type: String,
enum: ["user", "admin"], // กำหนดสิทธิ์ผู้ใช้
default: "user"
},
profile: { // เก็บข้อมูลส่วนตัวแยกเป็น object
bio: { type: String, default: '' },
avatar: { type: String, default: '' },
phone: { type: String, default: '' }
}
},
{ timestamps: true } // สร้าง createdAt, updatedAt อัตโนมัติ
);
export const User = mongoose.model("User", UserSchema);
2. Register API (สมัครสมาชิก)
ไฟล์: backend/src/routes/auth.js
/* backend/src/routes/auth.js */
// 1. รับข้อมูลจาก Client
authRouter.post("/register", async (req, res) => {
// 1.1 ตรวจสอบความถูกต้องของข้อมูล (Validation)
const schema = z.object({
email: z.string().email(),
password: z.string().min(6), // รหัสผ่านขั้นต่ำ 6 ตัว
displayName: z.string().optional()
});
const data = schema.parse(req.body);
// 1.2 เช็คว่าอีเมลนี้มีคนใช้ไปหรือยัง?
const exists = await User.findOne({ email: data.email });
if (exists) {
return res.status(409).json({ ok: false, message: "Email already used" });
}
// 1.3 ⚠️ ขั้นตอนสำคัญ: Hash Password
// ห้ามบันทึกรหัสผ่านดิบๆ ลง Database เด็ดขาด!
const passwordHash = await bcrypt.hash(data.password, 10);
// 1.4 บันทึกลงฐานข้อมูล
const user = await User.create({
email: data.email,
passwordHash, // เก็บตัวที่ Hash แล้ว
displayName: data.displayName || ""
});
// 1.5 สร้าง Token ให้ทันที (เพื่อให้ User ใช้งานได้เลยไม่ต้อง Login ซ้ำ)
const token = jwt.sign(
{ _id: user._id, role: user.role },
process.env.JWT_SECRET,
{ expiresIn: "30d" }
);
// 1.6 ส่งผลลัพธ์กลับไป
res.json({ ok: true, token, user });
});
3. Login API (เข้าสู่ระบบ)
authRouter.post("/login", async (req, res) => {
try {
const { email, password } = req.body;
// 1. ค้นหา User จาก Email
const user = await User.findOne({ email });
if (!user) {
// เทคนิค Security: ตอบแบบคลุมเครือว่าผิดที่อะไร
return res.status(401).json({ ok: false, message: "Invalid credentials" });
}
// 2. ตรวจสอบรหัสผ่าน
// เอา password ที่พิมพ์มา เทียบกับ passwordHash ใน DB
const isMatch = await bcrypt.compare(password, user.passwordHash);
if (!isMatch) {
return res.status(401).json({ ok: false, message: "Invalid credentials" });
}
// 3. สร้าง Token (บัตรผ่าน)
const token = jwt.sign(
{
_id: user._id,
email: user.email,
role: user.role
},
process.env.JWT_SECRET,
{ expiresIn: "30d" } // หมดอายุใน 30 วัน
);
res.json({ ok: true, token, user });
} catch (error) {
res.status(500).json({ ok: false, message: "Login failed" });
}
});
📱 การพัฒนาฝั่ง Frontend (Flutter)
1. ระบบจัดการ Session
ไฟล์: mobile/lib/services/session.dart
เปรียบเสมือนกระเป๋าสตางค์ที่เก็บ "บัตรผ่าน" (Token) เอาไว้
class Session {
static String? token; // เก็บ Token ใน RAM เพื่อความเร็ว
static Map<String, dynamic>? user; // เก็บข้อมูล User
// เริ่มต้น Load ข้อมูลจาก Disk ลง RAM
static Future<void> init() async {
final prefs = await SharedPreferences.getInstance();
token = prefs.getString('auth_token');
// Load User object (ที่เก็บเป็น JSON String)
final userStr = prefs.getString('auth_user');
if (userStr != null) {
user = jsonDecode(userStr);
}
}
// เมื่อ Login สำเร็จ ให้บันทึก
static Future<void> save(String newToken, Map<String, dynamic> newUser) async {
// 1. save to memory
token = newToken;
user = newUser;
// 2. save to disk (persistent)
final prefs = await SharedPreferences.getInstance();
await prefs.setString('auth_token', newToken);
await prefs.setString('auth_user', jsonEncode(newUser));
}
// เมื่อ Logout ให้ลบทิ้ง
static Future<void> clear() async {
token = null;
user = null;
final prefs = await SharedPreferences.getInstance();
await prefs.remove('auth_token');
await prefs.remove('auth_user');
}
// เช็คสถานะง่ายๆ ว่า Login อยู่ไหม
static bool get isLoggedIn => token != null;
}
2. Login Screen UI
ไฟล์: mobile/lib/screens/login_screen.dart
// ฟังก์ชันเมื่อกดปุ่ม Login
Future<void> _login() async {
setState(() => loading = true); // หมุนติ้วๆ
try {
// 1. เรียก API
final data = await AuthApi.login(
email: emailController.text,
password: passwordController.text,
);
// 2. ถ้าผ่าน ให้บันทึก Session
await Session.save(data['token'], data['user']);
// 3. เปลี่ยนหน้าไป AppShell (หน้าหลัก)
if (!mounted) return;
Navigator.pushReplacement(
context,
MaterialPageRoute(builder: (_) => const AppShell()),
);
} catch (e) {
// ถ้า Login ไม่ผ่าน แสดง Error
setState(() => error = "อีเมลหรือรหัสผ่านไม่ถูกต้อง");
} finally {
setState(() => loading = false); // หยุดหมุน
}
}
3. Splash Screen (Auto Login)
ไฟล์: mobile/lib/screens/splash_screen.dart
หน้าแรกสุดของแอป ทำหน้าที่เป็น "ฝ่ายคัดกรอง"
class _SplashScreenState extends State<SplashScreen> {
@override
void initState() {
super.initState();
_checkAuth();
}
Future<void> _checkAuth() async {
// 1. โหลดข้อมูล Session
await Session.init();
// 2. หน่วงเวลาเล็กน้อย (เพื่อความสวยงาม ไม่ให้แว้บไปมา)
await Future.delayed(const Duration(seconds: 1));
if (!mounted) return;
// 3. ตัดสินใจว่าจะไปไหน?
if (Session.isLoggedIn) {
// มี Token -> ไปหน้าหลักเลย
Navigator.pushReplacement(context, MaterialPageRoute(builder: (_) => const AppShell()));
} else {
// ไม่มี Token -> ไปหน้า Login
Navigator.pushReplacement(context, MaterialPageRoute(builder: (_) => const LoginScreen()));
}
}
}
🔐 Security Best Practices (ความปลอดภัย)
1. Password Hashing (การแฮชรหัสผ่าน)
- ทำไม? ถ้า Hacker ขโมยฐานข้อมูลไป เขาจะได้เห็นรหัสผ่านของทุกคนหรือไม่?
- คำตอบ: ถ้าไม่ Hash เขาจะเห็น
123456ทันที แต่ถ้าใช้bcryptเขาจะเห็น$2b$10$nOu...ซึ่งแกะกลับมาเป็นรหัสเดิมไม่ได้ - สิ่งที่ QUOM ใช้:
bcrypt.hash(password, 10)
2. JWT Secret Management
- ทำไม? Secret Key เปรียบเสมือน "ลายเซ็น" ของผู้ออกบัตร ถ้าใครรู้ เขาจะปลอมบัตรผ่าน (Token) ได้เอง
- วิธีแก้: เก็บไว้ใน
.envห้ามใส่ใน Code และต้องมีความยาว/ซับซ้อนพอสมควร
3. Token Storage on Mobile
- ทำไม? Token ก็เหมือนกุญแจบ้าน
- วิธีแก้: QUOM ใช้
SharedPreferencesซึ่งใน iOS จะถูกเข้ารหัสโดยอัตโนมัติ (แต่ใน Android ควรอัพเกรดเป็นFlutterSecureStorageหากต้องการความปลอดภัยระดับสูงสำหรับการเงิน)
4. Generic Error Messages
- ทำไม? ไม่ควรบอกว่า "Email นี้ไม่พบในระบบ" หรือ "รหัสผ่านผิด" ชัดเจนเกินไป เพราะ Hacker จะใช้เดา Email ของ User ได้
- วิธีแก้: บอกรวมๆ ว่า "Email หรือรหัสผ่านไม่ถูกต้อง" (Invalid credentials)
📝 แบบฝึกหัด
โจทย์ที่ 1: ระบบ "ลืมรหัสผ่าน" (Forgot Password)
สิ่งที่ต้องทำ:
- สร้างหน้าจอ "กรอก Email" เพื่อขอรหัสใหม่
- เชื่อมต่อ API
auth/forgot-password(จากบทที่ 7) - สร้างหน้าจอ "กรอก OTP และ รหัสผ่านใหม่"
โจทย์ที่ 2: ระบบ "Remember Me"
สิ่งที่ต้องทำ:
- เพิ่ม Checkbox "จำฉันไว้ในระบบ" ในหน้า Login
- ถ้าผู้ใช้ ไม่ติ๊ก ให้ใช้
Sessionแบบชั่วคราว (ไม่บันทึกลง Disk) หรือลบ Token ทิ้งเมื่อปิดแอป
โจทย์ที่ 3: Token Expiration Handling
สิ่งที่ต้องทำ:
- Token ของเราหมดอายุใน 30 วัน
- ถ้า Token หมดอายุ API จะตอบกลับมาเป็น
401 Unauthorized - เขียนโค้ดใน
Interceptorหรือapi_config.dartให้ดีด user กลับไปหน้า Login อัตโนมัติเมื่อเจอ 401
📌 สรุป
ในบทนี้เราได้เรียนรู้ระบบหัวใจสำคัญของแอปพลิเคชัน นั่นคือ Authentication
- Backend: ทำหน้าที่ตรวจสอบและออกบัตรผ่าน (Token)
- Frontend: ทำหน้าที่เก็บรักษาบัตรผ่าน และยื่นบัตรทุกครั้งที่ขอข้อมูล
- Security: การเก็บรักษาความปลอดภัยของข้อมูลผู้ใช้เป็นสิ่งสำคัญที่สุด
ไฟล์ที่เกี่ยวข้อง
backend/src/routes/auth.jsbackend/src/models/User.jsmobile/lib/services/session.dartmobile/lib/screens/login_screen.dart
🔜 บทถัดไป
บทที่ 18: Real-time Features (Chat) เราจะนำ User ที่ Login เข้ามาแล้ว มาคุยกับ AI กันครับ!