Register, Login & Logout
สิ่งที่จะสร้าง
หัวข้อที่มีชื่อว่า “สิ่งที่จะสร้าง”handlers.rs มีสาม handler — register, login, logout — บวก struct serde สำหรับ request/response ที่แต่ละตัวต้องการ และ query sqlx ที่รองรับ register กับ login จากนั้น auth/mod.rs จะได้ pub fn routes() -> Router<AppState> ที่เชื่อมทั้งสามตัวเข้ากับ POST /register, POST /login และ POST /logout และ main.rs mount router นั้นใต้ /auth ด้วย .nest
นี่คือบทเรียนที่ทุกอย่างจาก Module 4 มารวมกัน: password::hash_password/verify_password, jwt::issue/verify, pattern Redis allowlist จาก middleware.rs และ extractor AuthUser ทั้งหมดได้ใช้งานจริงเป็นครั้งแรก โดย handler จริง กับ table users จริง
register และ login ต่างจบด้วยสองขั้นตอนเดียวกันเป๊ะ — ออก JWT แล้วเก็บ jti ของ token ไว้ใน Redis allowlist — logic ชุดนั้นจึงอยู่ใน helper issue_and_store ตัวเดียวที่ทั้งสอง handler เรียก แทนที่จะ copy-paste ซ้ำสองครั้ง
ส่วน logout คือ handler ตัวเดียวใน module นี้ที่ ต้องการ AuthUser เพราะจะ revoke token ไม่ได้เลยถ้าไม่พิสูจน์ก่อนว่าผู้เรียกถือ token ที่ valid อยู่จริง และ AuthUser รับประกันข้อนี้ให้ตั้งแต่ก่อน body ของ logout จะเริ่มรัน
การเก็บรูปแบบ request/response ไว้เป็น struct serde ของตัวเอง — แทนที่จะ serialize UserRow (แถวภายในของ table users ที่มี password_hash รวมอยู่) ตรง ๆ — ทำให้รูปแบบข้อมูลที่ส่งออกไปเป็นการตัดสินใจอย่างมีสติ ไม่ใช่อุบัติเหตุจาก column ที่บังเอิญอยู่ใน table
ตัว UserResponse ไม่มี field password_hash ให้รั่วได้ตั้งแต่แรก เป็นเพราะโครงสร้างของ type เอง ไม่ใช่เพราะต้องคอยจำไปไล่ skip ทุกจุดที่เรียก
ข้อดีข้อเสีย
หัวข้อที่มีชื่อว่า “ข้อดีข้อเสีย”DTO เฉพาะสำหรับ request/response (ตัวที่เราใช้) เทียบกับ serialize แถว users ตรง ๆ
- ข้อดี:
UserResponse { id, email, display_name }ไม่มีทางรั่วpassword_hashใน JSON body ได้โดยไม่ตั้งใจ เพราะ field นั้นไม่มีอยู่ใน type นี้เลย — ไม่มี#[serde(skip)]ที่ต้องจำไว้เวลาเพิ่ม column ใหม่ในusersปีหน้า DTO ฝั่ง request (RegisterRequest,LoginRequest) ก็ตั้งใจไม่แพ้กัน — รับแค่{ email, password, display_name }เป๊ะ ไม่มีอะไรเกินกว่าที่ schema ฐานข้อมูลต้องการ map แบบหนึ่งต่อหนึ่งจาก JSON ของ client - ข้อเสีย: มี struct เพิ่มอีกตัวที่ต้องคอยเช็คให้ตรงกันเองด้วยมือทุกครั้งที่
usersเปลี่ยน เพิ่ม column ทีเดียวอาจต้องอัปเดตทั้งUserRow(struct ฝั่ง SQL) และUserResponse(struct ฝั่ง client) ส่วนการ serialize แถวตรง ๆ แก้แค่จุดเดียว นี่คือ trade-off ที่ถูกต้องสำหรับ auth โดยเฉพาะ เพราะแถวข้อมูลมีความลับที่ response ต้องไม่พกออกไปเด็ดขาด
ลอง INSERT แล้วเช็ค unique-violation error (ตัวที่เราใช้) เทียบกับ SELECT หา email ที่มีอยู่ก่อนแล้วค่อย INSERT
- ข้อดี: round trip เดียวไป Postgres แทนที่จะเป็นสองรอบ และ — สำคัญกว่านั้น — ไม่มี race condition: request
registerสองตัวที่เกิดพร้อมกันสำหรับ email เดียวกัน ไม่มีทางผ่านการเช็ค “email นี้มีอยู่ไหม” แล้วINSERTสำเร็จทั้งคู่ได้ เพราะ constraintuniqueของ Postgres เองบนusers.emailเป็นแหล่งความจริงเดียว ถูกเช็คแบบ atomic โดยฐานข้อมูลเอง นี่คือสถานการณ์เป๊ะ ๆ ที่ข้อเสียของ#[from]ในบทเรียน error-handling เตือนไว้ล่วงหน้าแล้ว: “ถ้า handler ต้องตอบสนองต่างกันระหว่าง duplicate-key error กับ… handler ตัวนั้นต้อง pattern-matchsqlx::Errorเอง ก่อนที่?จะทำงาน” — นั่นคือสิ่งที่registerทำตรงนี้เป๊ะ ๆ ด้วยการ matchsqlx::Error::Database(db_err)และเช็คdb_err.is_unique_violation()ก่อนปล่อยให้?แปลงอะไรก็ตามที่เหลือเป็นAppError::Db - ข้อเสีย: logic การเช็คซ้ำอยู่ในตัว handler เอง แทนที่จะเป็น query “email นี้มีอยู่ไหม” ที่ใช้ซ้ำได้ตัวเดียว ยอมรับได้ในที่นี้เพราะ
registerเป็นที่เดียวที่ต้องใช้
logout extract bearer token ใหม่เพื่อดึง jti ของ token นั้น (ตัวที่เราใช้) เทียบกับให้ AuthUser พก jti ไปด้วยเลย
- ข้อดี:
AuthUserมี field เดียวคือUuid— user id ที่เป็นทุกอย่างที่ handler ที่ต้อง login ตัวอื่นทั้งหมด ต้องการจริง ๆ มีแค่logoutที่ต้องรู้เพิ่มว่าจะ revoke token ไหน และได้ค่านั้นมาด้วยการเรียก helperbearer_tokenตัวเดียวกับที่AuthUser::from_request_partsใช้ไปแล้ว บน headerAuthorizationเดียวกัน แล้วส่ง token นั้นให้jwt::verifyอีกครั้งเพื่อกู้claims.jtiกลับมา - ข้อเสีย:
logoutต้องจ่ายค่า verify signature รอบที่สอง ทั้งที่AuthUserทำไปแล้วหนึ่งรอบใน request เดียวกัน นี่เป็น trade-off ที่จงใจและถูก (เช็ค HMAC เพิ่มหนึ่งครั้ง ไม่มี I/O เพิ่ม) เทียบกับการทำให้AuthUserกว้างขึ้นด้วย field ที่มี handler เดียวในทั้ง API ต้องใช้
ลงมือสร้าง
หัวข้อที่มีชื่อว่า “ลงมือสร้าง”1. DTO ฝั่ง request และ response บวก UserRow สำหรับ projection ของ SQL
หัวข้อที่มีชื่อว่า “1. DTO ฝั่ง request และ response บวก UserRow สำหรับ projection ของ SQL”สิ่งเหล่านี้อยู่ด้านบนสุดของ handlers.rs — ดูไฟล์เต็มด้านล่าง RegisterRequest และ LoginRequest คือสิ่งที่ Json<...> deserialize body ของ request เข้าไป; UserResponse และ AuthResponse คือสิ่งที่ handler คืนออกมา; UserRow คือ struct แบบ private ที่ #[derive(sqlx::FromRow)] ใช้แค่ดึง column ออกจากแถว users
2. handlers.rs
หัวข้อที่มีชื่อว่า “2. handlers.rs”สร้าง taskflow/backend/api/src/auth/handlers.rs:
use axum::{extract::State, http::HeaderMap, http::StatusCode, Json};use deadpool_redis::redis::AsyncCommands;use serde::{Deserialize, Serialize};use uuid::Uuid;
use crate::{ auth::{jwt, middleware, middleware::AuthUser, password}, error::{AppError, AppResult}, state::AppState,};
#[derive(Debug, Deserialize)]pub struct RegisterRequest { pub email: String, pub password: String, pub display_name: String,}
#[derive(Debug, Deserialize)]pub struct LoginRequest { pub email: String, pub password: String,}
#[derive(Debug, Serialize)]pub struct UserResponse { pub id: Uuid, pub email: String, pub display_name: String,}
#[derive(Debug, Serialize)]pub struct AuthResponse { pub token: String, pub user: UserResponse,}
#[derive(sqlx::FromRow)]struct UserRow { id: Uuid, email: String, password_hash: String, display_name: String,}
/// Issues a fresh JWT for `user_id` and stores its `jti` in the Redis/// allowlist with a 24h TTL, matching the token's own expiry.async fn issue_and_store(state: &AppState, user_id: Uuid) -> AppResult<String> { let (token, jti) = jwt::issue(user_id, &state.config.jwt_secret).map_err(AppError::Internal)?;
let mut conn = state .redis .get() .await .map_err(|err| AppError::Internal(err.into()))?;
let key = format!("auth:token:{jti}"); let _: () = conn .set_ex(&key, user_id.to_string(), 86_400) .await .map_err(|err| AppError::Internal(err.into()))?;
Ok(token)}
pub async fn register( State(state): State<AppState>, Json(body): Json<RegisterRequest>,) -> AppResult<Json<AuthResponse>> { let password_hash = password::hash_password(&body.password).map_err(AppError::Internal)?; let user_id = Uuid::new_v4();
let insert = sqlx::query( "INSERT INTO users (id, email, password_hash, display_name) VALUES ($1, $2, $3, $4)", ) .bind(user_id) .bind(&body.email) .bind(&password_hash) .bind(&body.display_name) .execute(&state.db) .await;
if let Err(sqlx::Error::Database(db_err)) = &insert { if db_err.is_unique_violation() { return Err(AppError::Conflict("email already registered".to_string())); } } insert?;
let token = issue_and_store(&state, user_id).await?;
Ok(Json(AuthResponse { token, user: UserResponse { id: user_id, email: body.email, display_name: body.display_name, }, }))}
pub async fn login( State(state): State<AppState>, Json(body): Json<LoginRequest>,) -> AppResult<Json<AuthResponse>> { let user = sqlx::query_as::<_, UserRow>( "SELECT id, email, password_hash, display_name FROM users WHERE email = $1", ) .bind(&body.email) .fetch_optional(&state.db) .await? .ok_or(AppError::Unauthorized)?;
if !password::verify_password(&body.password, &user.password_hash) { return Err(AppError::Unauthorized); }
let token = issue_and_store(&state, user.id).await?;
Ok(Json(AuthResponse { token, user: UserResponse { id: user.id, email: user.email, display_name: user.display_name, }, }))}
pub async fn logout( State(state): State<AppState>, AuthUser(_user_id): AuthUser, headers: HeaderMap,) -> AppResult<StatusCode> { // `AuthUser` already proved the token is valid and not yet revoked; // re-extract it here only to recover the `jti` this specific token // carries, so we delete exactly one session instead of every session // belonging to the user. let token = middleware::bearer_token(&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 _: () = conn .del(format!("auth:token:{}", claims.jti)) .await .map_err(|err| AppError::Internal(err.into()))?;
Ok(StatusCode::NO_CONTENT)}มีรายละเอียดสองสามอย่างที่ควรพูดถึง:
- กรณี “email ผิด” กับ “รหัสผ่านผิด” ของ
loginต่างคืนAppError::Unauthorizedเดียวกันเป๊ะ — ไม่เคยเป็นAppError::NotFoundสำหรับ email ที่ไม่รู้จัก การแยกทั้งสองกรณีออกจากกันใน response จะมอบ “email ไหนบ้างที่ลงทะเบียนไว้” เป็น oracle ฟรี ๆ ให้ผู้โจมตี จากมุมมองของ client ทั้งสองความล้มเหลวควรดูเหมือนกันเป๊ะ sqlx::query(...)และsqlx::query_as::<_, UserRow>(...)เป็นสไตล์ query แบบ runtime ที่ไม่ compile-time-check แบบเดียวกับที่find_board_titleใช้ตอนอยู่ใน error-handling — ไม่ใช่ macroquery!/query_as!ซึ่งต้องมีDATABASE_URLที่ยังทำงานอยู่ (หรือ offline metadata ที่แคชไว้) ตอนcargo build- TTL ของ Redis ใน
issue_and_storeคือ86_400วินาที — 24 ชั่วโมง — ตั้งใจให้ตรงกับexpของjwt::issueเอง ถ้าทั้งสองค่าเบี่ยงกันเมื่อไหร่ ตัวที่ สั้นกว่า จะชนะในทางปฏิบัติ: ไม่ JWT หมดอายุก่อน (ไม่เป็นไร เพราะverifyปฏิเสธอยู่แล้ว) ก็ key ของ Redis หมดอายุก่อน (token ยังมี signature ที่ valid อยู่ แต่นับว่า revoke ไปแล้ว) การตั้ง TTL ทั้งสองให้เท่ากันช่วยให้ไม่ต้องคิดเรื่อง gap นี้เลย
3. เปิด route ออกจาก auth/mod.rs
หัวข้อที่มีชื่อว่า “3. เปิด route ออกจาก auth/mod.rs”แทนที่ taskflow/backend/api/src/auth/mod.rs ด้วย:
pub mod handlers;pub mod jwt;pub mod middleware;pub mod password;
use axum::{routing::post, Router};
use crate::state::AppState;
/// All `/auth/*` routes: register, login, logout.pub fn routes() -> Router<AppState> { Router::new() .route("/register", post(handlers::register)) .route("/login", post(handlers::login)) .route("/logout", post(handlers::logout))}4. Mount /auth ใน main.rs
หัวข้อที่มีชื่อว่า “4. Mount /auth ใน main.rs”อัปเดต chain ของ Router::new() ใน taskflow/backend/api/src/main.rs:
let app = Router::new() .route("/health", get(health)) .nest("/auth", auth::routes()) .layer(cors) .with_state(state);Router::nest("/auth", auth::routes()) เติม prefix /auth ให้ทุก route ที่ auth::routes() นิยามไว้ ดังนั้น POST /register ภายใน router นั้นจะกลายเป็น POST /auth/register บน server ที่รันอยู่
และเพราะ auth::routes() คืน Router<AppState> ที่เป็น state ประเภทเดียวกับที่ router ด้านนอกถืออยู่แล้ว .nest จึงประกอบเข้าด้วยกันได้เลยโดยไม่ต้องต่อสายเพิ่ม
ตรวจสอบผล
หัวข้อที่มีชื่อว่า “ตรวจสอบผล”เปิด Postgres และ Redis ขึ้นมา แล้วรัน API:
cd taskflow/infra && docker compose up -d db rediscd ../backend && RUST_LOG=info cargo run -p apiลงทะเบียนผู้ใช้:
curl -s -X POST http://localhost:8080/auth/register \ -H "Content-Type: application/json" \ -d '{"email":"ada@example.com","password":"correct horse battery staple","display_name":"Ada"}'{"token":"eyJ...","user":{"id":"...","email":"ada@example.com","display_name":"Ada"}}ลงทะเบียน email เดิมซ้ำจะ fail ถูกต้อง:
curl -s -o /dev/null -w "%{http_code}\n" -X POST http://localhost:8080/auth/register \ -H "Content-Type: application/json" \ -d '{"email":"ada@example.com","password":"anything","display_name":"Ada"}'409Login แล้วเก็บ token ไว้:
TOKEN=$(curl -s -X POST http://localhost:8080/auth/login \ -H "Content-Type: application/json" \ -d '{"email":"ada@example.com","password":"correct horse battery staple"}' | python3 -c 'import json,sys; print(json.load(sys.stdin)["token"])')เรียก route ที่ต้อง login ด้วย token นั้น — ยังไม่มี route board แต่ /auth/logout เองก็ต้องการ AuthUser อยู่แล้ว จึงใช้เป็นตัวเช็ค route ที่ต้อง login ไปในตัว:
curl -s -o /dev/null -w "%{http_code}\n" -X POST http://localhost:8080/auth/logout \ -H "Authorization: Bearer $TOKEN"204ยืนยันว่า token โดน revoke แล้ว — เรียก /auth/logout อีกครั้งด้วย token เดิมจะ fail เพราะ jti ของ token นั้นไม่อยู่ใน Redis แล้ว:
curl -s -o /dev/null -w "%{http_code}\n" -X POST http://localhost:8080/auth/logout \ -H "Authorization: Bearer $TOKEN"401และ request ที่ไม่มี header Authorization เลยก็ fail แบบเดียวกัน ก่อนที่จะไปถึง body ของ handler ด้วยซ้ำ:
curl -s -o /dev/null -w "%{http_code}\n" -X POST http://localhost:8080/auth/logout401คุณเขียน register, login และ logout ใน handlers.rs โดยมี DTO สำหรับ request/response เฉพาะทางที่กัน password_hash ออกจากทุก JSON response ด้วยโครงสร้างของ type เอง และ query sqlx ที่ยก constraint unique บน email ของ Postgres มาแก้ race condition ของการลงทะเบียนซ้ำแบบ atomic
ฝั่ง routing ตัว auth::routes() ใน auth/mod.rs เชื่อมทั้งสาม handler เข้ากับ Router<AppState> แล้ว main.rs mount router นั้นด้วย .nest("/auth", auth::routes()) เท่านี้ก็ปิด Module 4 — ตอนนี้ TaskFlow มีบัญชีผู้ใช้จริง, การ hash รหัสผ่านด้วย Argon2, JWT ที่เซ็นและ revoke ได้ และ extractor AuthUser ที่ทุก route ต้อง login ใน module REST API จะได้ใช้ต่อ