บทที่ 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

องค์ประกอบสำคัญ

  1. Backend (บริการ):

    • Register: รับข้อมูล -> ตรวจสอบ -> Hash รหัสผ่าน -> บันทึกลงฐานข้อมูล
    • Login: รับข้อมูล -> ตรวจสอบ -> สร้าง Token แจกให้
    • Middleware: ด่านตรวจคนเข้าเมือง คอยเช็ค Token ในทุก Request
  2. 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)

สิ่งที่ต้องทำ:

  1. สร้างหน้าจอ "กรอก Email" เพื่อขอรหัสใหม่
  2. เชื่อมต่อ API auth/forgot-password (จากบทที่ 7)
  3. สร้างหน้าจอ "กรอก OTP และ รหัสผ่านใหม่"

โจทย์ที่ 2: ระบบ "Remember Me"

สิ่งที่ต้องทำ:

  1. เพิ่ม Checkbox "จำฉันไว้ในระบบ" ในหน้า Login
  2. ถ้าผู้ใช้ ไม่ติ๊ก ให้ใช้ Session แบบชั่วคราว (ไม่บันทึกลง Disk) หรือลบ Token ทิ้งเมื่อปิดแอป

โจทย์ที่ 3: Token Expiration Handling

สิ่งที่ต้องทำ:

  1. Token ของเราหมดอายุใน 30 วัน
  2. ถ้า Token หมดอายุ API จะตอบกลับมาเป็น 401 Unauthorized
  3. เขียนโค้ดใน Interceptor หรือ api_config.dart ให้ดีด user กลับไปหน้า Login อัตโนมัติเมื่อเจอ 401

📌 สรุป

ในบทนี้เราได้เรียนรู้ระบบหัวใจสำคัญของแอปพลิเคชัน นั่นคือ Authentication

  • Backend: ทำหน้าที่ตรวจสอบและออกบัตรผ่าน (Token)
  • Frontend: ทำหน้าที่เก็บรักษาบัตรผ่าน และยื่นบัตรทุกครั้งที่ขอข้อมูล
  • Security: การเก็บรักษาความปลอดภัยของข้อมูลผู้ใช้เป็นสิ่งสำคัญที่สุด

ไฟล์ที่เกี่ยวข้อง


🔜 บทถัดไป

บทที่ 18: Real-time Features (Chat) เราจะนำ User ที่ Login เข้ามาแล้ว มาคุยกับ AI กันครับ!