Auth Middleware
สิ่งที่จะสร้าง
หัวข้อที่มีชื่อว่า “สิ่งที่จะสร้าง”middleware.rs มี extractor AuthUser(pub uuid::Uuid) ที่ implement FromRequestParts<AppState> ของ Axum ใส่ AuthUser เป็น argument ใน handler ตัวไหน handler ตัวนั้นก็บังคับให้ request ต้องมี header Authorization: Bearer <token> ที่ valid และยังไม่โดน revoke ทันที
Axum รันการ extract ก่อน body ของ handler จะทำงาน และถ้า extract ไม่ผ่านก็ตัดจบไปที่ HTTP response เลย ไม่ต้องเขียน if เช็คเองในตัว handler สักบรรทัด
ภายใต้ผิว AuthUser ทำสามอย่างตามลำดับ: ดึง bearer token ออกจาก header Authorization, verify signature และวันหมดอายุด้วย jwt::verify จากบทเรียนก่อน แล้วยืนยันว่า jti ยังอยู่ใน Redis allowlist (auth:token:{jti}) ก่อนจะเชื่อถือ token นั้น
ทุก route ที่ต้อง login ใน module REST API ต้องการการเช็คสามอย่างเหมือนกัน — มี token ไหม, เป็น JWT ที่เซ็นถูกต้องและยังไม่หมดอายุไหม, โดน revoke ไปแล้วหรือยัง — และ logic ชุดนี้ไม่ควรไป duplicate อยู่ในทุก handler
ระบบ extractor ของ Axum มีไว้เพื่อสิ่งนี้โดยเฉพาะ: type ที่ implement FromRequestParts ใส่เป็น argument ของ handler ได้ แล้ว Axum จะรัน logic การ extract ให้อัตโนมัติก่อนโค้ดของ handler โดยใช้แค่ Parts ของ request (method, URI, header — ทุกอย่างยกเว้น body) กับ AppState ที่แชร์กันอยู่
นั่นเปลี่ยน “route นี้ต้องการผู้ใช้ที่ login แล้ว” ให้กลายเป็น type signature ลองเทียบ async fn get_board(State(state): State<AppState>, Path(id): Path<Uuid>) (ไม่ต้อง login) กับ async fn get_board(State(state): State<AppState>, AuthUser(user_id): AuthUser, Path(id): Path<Uuid>) (ต้อง login) เวอร์ชันที่สองเรียกไม่ได้เลยถ้าไม่มี token ที่ valid ผ่าน AuthUser::from_request_parts มาก่อน และ compiler ก็บังคับให้ทุก handler ที่ต้องใช้ user_id ขอเข้ามาตรง ๆ
ข้อดีข้อเสีย
หัวข้อที่มีชื่อว่า “ข้อดีข้อเสีย”extractor FromRequestParts แบบกำหนดเอง (ตัวที่เราใช้) เทียบกับ tower middleware layer
- ข้อดี:
AuthUserเป็น opt-in ต่อ route ในระดับ type — handler จะขอAuthUserใน signature หรือไม่ก็ได้ ส่วน route สาธารณะ (register,login,/health) ก็แค่ไม่ใส่เข้าไป ขณะที่tower::Layerห่อทั้งRouterหรือกลุ่ม route ทั้งกลุ่มแบบเดียวกันหมด แปลว่าต้องสร้าง router ที่สองสำหรับ route สาธารณะ หรือไม่ก็ร้อย logic “path นี้ยกเว้นไหม” เข้าไปใน layer เอง สำหรับชุด route ที่ endpoint ส่วนใหญ่ — แต่ไม่ใช่ทั้งหมด — ต้อง login แบบนี้ ตัว extractor ทำให้คำตอบของ “อันนี้ต้อง login ไหม” อยู่ที่เดียวและอ่านออกได้จาก signature ของแต่ละ handler - ข้อเสีย: extractor รันเฉพาะกับ handler ที่ใส่เข้าไปเท่านั้น จึงไม่มีจุดเดียวที่มองเห็นว่า “ทุก route ใต้
/boardsต้อง login” แบบที่.route_layer()บน router ที่ nest ไว้ให้ได้ ถ้าวันหนึ่ง TaskFlow มี block ใหญ่ของ route ที่ต้อง login เหมือนกันหมดtowerlayer ที่ห่อแค่ sub-router นั้น (โดยยังสร้างบน extractorAuthUserเดิมภายใน) ก็คุ้มที่จะกลับมาพิจารณาใหม่
type Rejection = AppError (ตัวที่เราใช้) เทียบกับ rejection type เฉพาะทาง
- ข้อดี:
AppErrorimplementIntoResponseอยู่แล้ว — ทุก variant map ไปที่ status code และรูปแบบ{ "error": "<code>", "message": "<text>" }เดียวกันกับที่ error ของทุก handler สร้างออกมา การใช้ซ้ำจึงทำให้ token ที่หมดอายุ, token ที่โดน revoke และ header ที่หายไป fail ด้วย JSON body401 unauthorizedแบบเดียวกันเป๊ะกับที่Err(AppError::Unauthorized)ในตัว handler จะสร้างออกมา — รูปแบบ error เดียวทั่วทั้ง API รวมถึงส่วน auth ด้วย - ข้อเสีย:
AppErrorมี variant (NotFound,Conflict, …) ที่ไม่สมเหตุสมผลเลยในฐานะความล้มเหลวของการ extract —AuthUserสร้างได้แค่UnauthorizedหรือInternalเท่านั้น ดังนั้น type นี้จึงกว้างกว่าที่จำเป็นจริง ๆ ในที่นี้ เป็น trade-off ที่ยอมรับได้ เพื่อไม่ต้องดูแลIntoResponseimpl ที่สองแค่สำหรับ extractor ตัวนี้ตัวเดียว
ลงมือสร้าง
หัวข้อที่มีชื่อว่า “ลงมือสร้าง”สร้าง taskflow/backend/api/src/auth/middleware.rs:
use axum::{ async_trait, extract::FromRequestParts, http::{header::AUTHORIZATION, request::Parts, HeaderMap},};use deadpool_redis::redis::AsyncCommands;use uuid::Uuid;
use crate::{auth::jwt, error::AppError, state::AppState};
/// The authenticated user's id, extracted from a valid, non-revoked bearer token.////// Any handler that adds `AuthUser` as an argument automatically requires/// a valid `Authorization: Bearer <token>` header — Axum runs the extractor/// before the handler body, and a failed extraction short-circuits straight/// to the `AppError::Unauthorized` response.pub struct AuthUser(pub Uuid);
/// Pulls the bearer token out of the `Authorization` header.////// Shared by the `AuthUser` extractor and the `logout` handler, which both/// need the raw token — the extractor to validate it, `logout` to know which/// `jti` to revoke.pub(crate) fn bearer_token(headers: &HeaderMap) -> Result<&str, AppError> { let header = headers .get(AUTHORIZATION) .and_then(|value| value.to_str().ok()) .ok_or(AppError::Unauthorized)?;
header.strip_prefix("Bearer ").ok_or(AppError::Unauthorized)}
#[async_trait]impl FromRequestParts<AppState> for AuthUser { type Rejection = AppError;
async fn from_request_parts( parts: &mut Parts, state: &AppState, ) -> Result<Self, Self::Rejection> { let token = bearer_token(&parts.headers)?;
let claims = jwt::verify(token, &state.config.jwt_secret).map_err(|_| AppError::Unauthorized)?;
let mut conn = state .redis .get() .await .map_err(|err| AppError::Internal(err.into()))?;
let session: Option<String> = conn .get(format!("auth:token:{}", claims.jti)) .await .map_err(|err| AppError::Internal(err.into()))?;
if session.is_none() { return Err(AppError::Unauthorized); }
let user_id = claims .sub .parse::<Uuid>() .map_err(|_| AppError::Unauthorized)?;
Ok(AuthUser(user_id)) }}มีรายละเอียดสองสามอย่างที่ควรพูดถึง:
FromRequestParts<AppState>— ไม่ใช่FromRequestParts<S>แบบ generic ที่ docs ของ Axum เองแสดงไว้ — เพราะAuthUserต้องการstate.config.jwt_secretและstate.redisโดยเฉพาะ การผูก impl เข้ากับAppStateที่เป็นรูปธรรมของเรา (แทนที่จะ generic ข้ามSไหนก็ได้) เหมาะเป๊ะสำหรับ extractor เฉพาะแอปแบบนี้ ตรงข้ามกับ extractor แบบ library ที่ใช้ซ้ำได้#[async_trait]จำเป็นต้องมีตรงนี้: traitFromRequestPartsของ Axum 0.7 เองถูกนิยามด้วย macroasync_traitดังนั้นimplที่เขียนมือ (ตรงข้ามกับ helper สไตล์#[derive(FromRequestParts)]ซึ่งใช้ไม่ได้กับ extractor ที่เขียนมือแบบนี้) ต้องใช้ macro เดียวกันเพื่อสร้าง signature ที่ตรงกันbearer_tokenเป็นฟังก์ชันเล็ก ๆ แยกออกมา ไม่ได้ inline เข้าไปในfrom_request_parts— handlers ใช้ซ้ำเป๊ะ ๆ ในlogoutที่ต้องการ token ดิบด้วยเหตุผลเดียวกัน (เพื่อกู้jti) โดยไม่ต้อง duplicate logic การ parse header- การเช็ค Redis query ที่ key
auth:token:{jti}— key เป๊ะ ๆ ที่registerและloginจะเขียนในบทเรียนถัดไป — และถือว่า key ที่หายไปคือUnauthorizedไม่ใช่ “fail open” token ที่มี signature valid สมบูรณ์แบบก็ยังโดนปฏิเสธตรงนี้ได้ และนั่นคือประเด็นทั้งหมด: นี่คือการเช็ค revocation ที่ JWT เปล่า ๆ ทำเองไม่ได้ state.redis.get()คืนdeadpool_redis::Connectionแบบ pooled สังเกตว่า import คือdeadpool_redis::redis::AsyncCommandsไม่ใช่ crateredisระดับบนสุดที่แยก version ต่างหาก —deadpool-redisre-exportredisเวอร์ชันเป๊ะที่ typeConnectionimplement ไว้ ถ้าดึง command trait มาจากที่อื่น trait bound จะไม่ครบและ compile ไม่ผ่านแบบงง ๆ
เพิ่ม middleware เข้า module auth
หัวข้อที่มีชื่อว่า “เพิ่ม middleware เข้า module auth”อัปเดต taskflow/backend/api/src/auth/mod.rs:
pub mod jwt;pub mod middleware;pub mod password;ตรวจสอบผล
หัวข้อที่มีชื่อว่า “ตรวจสอบผล”cargo check -p apiผลลัพธ์ที่ควรได้: compile ผ่านพร้อม warning ประเภท dead_code/never constructed อยู่บ้าง เพราะยังไม่มี handler ตัวไหนรับ AuthUser เป็น argument จนกว่าจะถึง handlers ที่เชื่อม logout เข้ามา นี่เป็นเรื่องปกติในขั้นนี้ เหมือนกับทุก module ที่เป็นโครงเตรียมไว้ก่อนหน้า
คุณสร้าง extractor AuthUser ใน middleware.rs โดย implement FromRequestParts<AppState> เพื่อเปลี่ยน “route นี้ต้องการผู้ใช้ที่ login แล้ว” ให้เป็นข้อกำหนดระดับ type แทนการไล่เช็คเองในทุก handler
ตัว AuthUser::from_request_parts ต่อการเช็คสามอย่างเป็นลูกโซ่ — มี bearer token, JWT signature กับวันหมดอายุยัง valid, jti ยังอยู่ใน Redis allowlist — แล้ว fail แบบปิดไปที่ AppError::Unauthorized ทันทีที่จุดแรกไม่ผ่าน ต่อไปเราจะเขียน handler register, login และ logout ที่ออก token, เติม allowlist และได้ใช้ AuthUser จริง ๆ ใน handlers