Bài 31.2: Typing custom authentication logic

Bài 31.2: Typing custom authentication logic
Chào mừng các bạn đến với bài viết chuyên sâu về việc nâng cao chất lượng code khi xử lý các luồng xác thực người dùng (authentication) trong ứng dụng front-end, đặc biệt là khi bạn đang làm việc với TypeScript và các framework như React hay Next.js. Bài hôm nay sẽ tập trung vào cách sử dụng TypeScript để định kiểu (typing) cho logic xác thực tùy chỉnh (custom authentication logic) của bạn.
Khi xây dựng ứng dụng web, xác thực là một phần cực kỳ quan trọng. Đôi khi, chúng ta không sử dụng các thư viện xác thực "có sẵn" hoàn toàn, mà cần triển khai logic riêng để giao tiếp với API backend tùy chỉnh, quản lý trạng thái người dùng, xử lý token, v.v. Đây chính là "custom authentication logic".
Vậy tại sao chúng ta cần phải "typing" cho nó? Đơn giản là vì TypeScript mang lại sự an toàn và minh bạch. Logic xác thực thường liên quan đến dữ liệu nhạy cảm (thông tin người dùng), các trạng thái phức tạp (đang tải, lỗi, đã đăng nhập/chưa đăng nhập), và các luồng xử lý bất đồng bộ. Nếu không có typing, code của bạn dễ trở nên lộn xộn, khó hiểu, và tiềm ẩn rất nhiều lỗi runtime không đáng có.
Thách Thức Khi Không Typing Logic Xác Thực
Hãy tưởng tượng bạn có một object đại diện cho người dùng đã đăng nhập. Nếu bạn không định kiểu nó, nó có thể chỉ là một object
hoặc any
.
// JavaScript hoặc TypeScript KHÔNG có typing rõ ràng
let currentUser = null; // Hoặc có thể là một object
async function login(username, password) {
// ... gọi API
const response = await fetch('/api/login', { /* ... */ });
const userData = await response.json(); // userData có cấu trúc gì?
if (response.ok) {
currentUser = userData; // Gán userData vào biến
console.log(currentUser.name); // Có chắc chắn có thuộc tính 'name'?
// ... lưu token, redirect, ...
} else {
// ... xử lý lỗi
}
}
// Ở một component khác
function UserProfile() {
// ... làm sao biết currentUser có tồn tại không?
// ... làm sao biết currentUser có những thuộc tính gì?
if (currentUser) {
return <div>Chào mừng, {currentUser.displayName}!</div>; // displayLame? Hay name? Hay username?
}
return null;
}
Trong ví dụ trên:
- Chúng ta không biết chắc chắn cấu trúc của
userData
trả về từ API. - Chúng ta không biết
currentUser
có những thuộc tính nào (name
,displayName
,username
,email
?). - Sẽ rất dễ gây ra lỗi runtime nếu truy cập một thuộc tính không tồn tại (
TypeError: Cannot read properties of undefined (reading 'displayName')
). - Việc debug và bảo trì code trở nên khó khăn vì thiếu thông tin rõ ràng về dữ liệu.
Đây chính là lúc TypeScript bước vào và giải cứu.
Sức Mạnh Của Typing Với TypeScript
TypeScript cho phép bạn định nghĩa rõ ràng hình dạng (shape) của dữ liệu. Khi áp dụng vào logic xác thực tùy chỉnh, bạn có thể:
- Định nghĩa cấu trúc dữ liệu người dùng: Bạn biết chính xác một object
User
trông như thế nào. - Định nghĩa cấu trúc dữ liệu yêu cầu/phản hồi API: Bạn biết dữ liệu gửi đi khi login/register cần gì và dữ liệu nhận về sẽ có gì.
- Định nghĩa trạng thái xác thực: Bạn biết trạng thái hiện tại (đang tải, đã đăng nhập, lỗi) được biểu diễn như thế nào.
- Định nghĩa chữ ký hàm (function signatures): Bạn biết các hàm
login
,logout
nhận tham số gì và trả về kiểu dữ liệu gì.
Điều này tạo ra một bức tường thành vững chắc, giúp trình biên dịch TypeScript bắt lỗi ngay trước khi bạn chạy ứng dụng, thay vì gặp lỗi lúc runtime.
Các Bước Typing Custom Authentication Logic
Chúng ta sẽ đi qua các bước cơ bản để áp dụng TypeScript vào logic xác thực:
1. Định Nghĩa Kiểu Dữ Liệu Người Dùng (User Data Type)
Đây là kiểu dữ liệu cốt lõi. Bạn cần xác định những thông tin nào về người dùng sẽ được lưu trữ và sử dụng ở phía client.
// src/types/auth.ts hoặc cùng file logic auth
export interface User {
id: string;
username: string;
email: string;
// Thêm các thuộc tính khác mà ứng dụng của bạn cần hiển thị hoặc sử dụng
// ví dụ: avatarUrl?: string; role?: 'admin' | 'user';
}
// Bạn cũng có thể định nghĩa kiểu cho dữ liệu dùng để đăng nhập/đăng ký
export interface LoginCredentials {
username: string;
password: string;
}
export interface RegisterData extends LoginCredentials {
email: string;
// Thêm các trường khác cần thiết cho đăng ký
}
- Giải thích: Chúng ta sử dụng
interface
(hoặctype
) để mô tả cấu trúc của một objectUser
. Mỗi thuộc tính có tên và kiểu dữ liệu rõ ràng (string
). Ký hiệu?
sau tên thuộc tính (avatarUrl?
) chỉ ra rằng thuộc tính này là tùy chọn (optional). Chúng ta cũng định nghĩa kiểu cho dữ liệu gửi đi (LoginCredentials
,RegisterData
).
2. Typing Các Hàm Xác Thực (Authentication Functions)
Định nghĩa rõ ràng tham số đầu vào và kiểu trả về của các hàm như login
, logout
, register
, checkAuthStatus
, v.v.
// Trong file xử lý logic auth (ví dụ: src/services/authService.ts)
import { User, LoginCredentials } from '../types/auth';
// Giả định hàm này gọi API backend
export async function login(credentials: LoginCredentials): Promise<User> {
console.log('Đang đăng nhập với:', credentials.username);
// TODO: Thực hiện gọi API thật sự
// Ví dụ giả lập:
await new Promise(resolve => setTimeout(resolve, 1000)); // Giả lập độ trễ mạng
if (credentials.username === 'test' && credentials.password === 'password') {
const fakeUser: User = {
id: 'user-123',
username: 'test',
email: 'test@example.com',
};
// TODO: Lưu token vào localStorage hoặc cookie
console.log('Đăng nhập thành công, trả về user:', fakeUser);
return fakeUser;
} else {
console.error('Đăng nhập thất bại: Sai tên đăng nhập hoặc mật khẩu');
throw new Error('Invalid username or password');
}
}
export async function logout(): Promise<void> {
console.log('Đang đăng xuất...');
// TODO: Gọi API logout (nếu có) và xóa token
await new Promise(resolve => setTimeout(resolve, 500)); // Giả lập độ trễ
// TODO: Xóa token khỏi localStorage/cookie
console.log('Đăng xuất thành công');
}
export async function checkAuthStatus(): Promise<User | null> {
console.log('Đang kiểm tra trạng thái xác thực...');
// TODO: Lấy token từ localStorage/cookie và gọi API kiểm tra token
await new Promise(resolve => setTimeout(resolve, 700)); // Giả lập độ trễ
// Ví dụ giả lập: kiểm tra localStorage
const token = localStorage.getItem('authToken'); // Giả định bạn lưu token ở đây
if (token) {
// Giả định API trả về thông tin user nếu token hợp lệ
// const user = await api.getUserFromToken(token);
const fakeUser: User = { // Giả lập user từ token
id: 'user-xyz',
username: 'authenticated_user',
email: 'auth@example.com'
};
console.log('Đã xác thực, user:', fakeUser);
return fakeUser;
}
console.log('Chưa xác thực');
return null;
}
- Giải thích:
- Hàm
login
nhậncredentials
có kiểuLoginCredentials
và trả về mộtPromise
sẽ giải quyết thành kiểuUser
. - Hàm
logout
không nhận tham số và trả vềPromise<void>
vì nó không cần trả về dữ liệu cụ thể. - Hàm
checkAuthStatus
không nhận tham số và trả vềPromise<User | null>
, cho biết nó có thể trả về một objectUser
nếu đã xác thực hoặcnull
nếu chưa. - Việc sử dụng
Promise<>
là quan trọng vì các hàm này thường bất đồng bộ. TypeScript giúp bạn đảm bảo bạn xử lý kết quả một cách đúng đắn (ví dụ: dùngawait
).
- Hàm
3. Typing Trạng Thái Xác Thực (Authentication State)
Trong ứng dụng front-end, trạng thái xác thực thường được quản lý trong state của component, context API, hoặc global state management (Redux, Zustand, v.v.). Việc định kiểu cho trạng thái này là cực kỳ quan trọng.
Một trạng thái xác thực điển hình có thể bao gồm:
user
: Thông tin người dùng (User | null
).null
nếu chưa đăng nhập.isLoading
: Boolean, cho biết đang trong quá trình kiểm tra trạng thái ban đầu hoặc đang xử lý login/logout.error
: String hoặc đối tượngError | null
, chứa thông báo lỗi nếu có vấn đề xảy ra.
// Trong file quản lý state/context (ví dụ: src/context/AuthContext.tsx)
import { User } from '../types/auth';
// Định nghĩa kiểu cho trạng thái xác thực
export interface AuthState {
user: User | null;
isLoading: boolean;
error: string | null; // Hoặc Error | null
}
// Định nghĩa kiểu cho giá trị được cung cấp bởi Context (bao gồm state và các hàm)
export interface AuthContextType extends AuthState {
login: (username: string, password: string) => Promise<void>;
logout: () => Promise<void>;
// Thêm các hàm khác nếu có (register, forgot password, ...)
}
- Giải thích:
AuthState
mô tả cấu trúc của dữ liệu trạng thái. Lưu ýuser
có thể làUser
hoặcnull
.AuthContextType
mở rộng từAuthState
và thêm các hàm hành động (login
,logout
) mà context cung cấp. Việc định kiểu cho các hàm này giúp đảm bảo khi sử dụng context, bạn gọi hàm với đúng tham số và xử lý kết quả mong đợi.
4. Sử Dụng Typing Trong Logic (Ví Dụ React Context)
Kết hợp các kiểu đã định nghĩa vào triển khai thực tế (ví dụ: sử dụng React Context để quản lý trạng thái xác thực).
// src/context/AuthContext.tsx (Tiếp theo từ bước 3)
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
import { login as authLogin, logout as authLogout, checkAuthStatus } from '../services/authService'; // Nhập các hàm từ service
import { AuthState, AuthContextType } from '../types/auth'; // Nhập các kiểu
// Khởi tạo Context với kiểu đã định nghĩa
const AuthContext = createContext<AuthContextType | undefined>(undefined);
// Provider Component
export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
const [state, setState] = useState<AuthState>({ // Sử dụng AuthState cho state
user: null,
isLoading: true, // Ban đầu là true để kiểm tra trạng thái
error: null,
});
// Effect kiểm tra trạng thái xác thực ban đầu khi ứng dụng load
useEffect(() => {
const checkStatus = async () => {
try {
const user = await checkAuthStatus(); // Hàm đã được typed trả về User | null
setState(prevState => ({ ...prevState, user, isLoading: false, error: null }));
} catch (err: any) { // Bắt lỗi và định kiểu cho err
setState(prevState => ({ ...prevState, user: null, isLoading: false, error: err.message }));
}
};
checkStatus();
}, []);
// Hàm login sử dụng kiểu từ AuthContextType
const login = async (username, password) => { // TS biết username, password là string nhờ AuthContextType
setState(prevState => ({ ...prevState, isLoading: true, error: null }));
try {
const user = await authLogin({ username, password }); // authLogin trả về Promise<User>
setState(prevState => ({ ...prevState, user, isLoading: false }));
// TODO: Có thể redirect hoặc thông báo thành công
} catch (err: any) {
setState(prevState => ({ ...prevState, user: null, isLoading: false, error: err.message }));
throw err; // Ném lỗi để component gọi handle
}
};
// Hàm logout sử dụng kiểu từ AuthContextType
const logout = async () => { // TS biết hàm này không nhận tham số và trả về Promise<void>
setState(prevState => ({ ...prevState, isLoading: true, error: null }));
try {
await authLogout(); // authLogout trả về Promise<void>
setState(prevState => ({ ...prevState, user: null, isLoading: false }));
// TODO: Có thể redirect về trang login
} catch (err: any) {
setState(prevState => ({ ...prevState, isLoading: false, error: err.message }));
throw err;
}
};
// Giá trị Context cung cấp, tuân thủ AuthContextType
const contextValue: AuthContextType = {
...state, // user, isLoading, error từ state
login, // hàm login đã định kiểu
logout, // hàm logout đã định kiểu
};
return (
<AuthContext.Provider value={contextValue}>
{children}
</AuthContext.Provider>
);
};
// Custom hook để sử dụng context dễ dàng
export const useAuth = () => {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context; // Trả về giá trị có kiểu AuthContextType
};
- Giải thích:
useState<AuthState>
đảm bảo state luôn có cấu trúc đúng nhưAuthState
.createContext<AuthContextType | undefined>(undefined)
tạo context với kiểu giá trị làAuthContextType
hoặcundefined
(trước khi provider được render).contextValue: AuthContextType = { ... }
ép buộc giá trị được cung cấp phải khớp với kiểuAuthContextType
, bao gồm cảuser
,isLoading
,error
, và các hàmlogin
,logout
.- Hàm
login
vàlogout
gọi các hàm đã được typed từauthService
. TypeScript sẽ kiểm tra xem bạn có gọi đúng cách không (ví dụ: truyền đúng tham số choauthLogin
). - Hook
useAuth
trả vềcontext
có kiểuAuthContextType
, giúp bạn an toàn khi truy cậpuser
,isLoading
,login
,logout
trong các component sử dụng hook này. Nếu bạn cố gắng truy cậpcontext.user.age
màage
không có trongUser
interface, TypeScript sẽ báo lỗi ngay lập tức.
5. Sử Dụng Hook useAuth
Trong Components
Khi sử dụng hook useAuth
trong các component, bạn sẽ thấy rõ lợi ích của việc typing.
// src/components/UserProfile.tsx
import React from 'react';
import { useAuth } from '../context/AuthContext'; // Nhập hook
function UserProfile() {
const { user, isLoading, logout, error } = useAuth(); // Các thuộc tính này đã được typed nhờ useAuth hook
if (isLoading) {
return <div>Đang tải thông tin người dùng...</div>;
}
if (error) {
return <div>Lỗi: {error}</div>;
}
if (!user) {
// Nếu chưa đăng nhập, có thể hiển thị nút login hoặc redirect
return <div>Bạn chưa đăng nhập.</div>;
}
// Khi user đã tồn tại và có kiểu User, bạn có thể truy cập an toàn các thuộc tính của nó
return (
<div>
<h2>Xin chào, {user.username}!</h2> {/* TS biết user có thuộc tính username */}
<p>Email: {user.email}</p> {/* TS biết user có thuộc tính email */}
{/* Nếu bạn có user.avatarUrl (optional) */}
{user.avatarUrl && <img src={user.avatarUrl} alt="Avatar" />}
<button onClick={() => logout()}>Đăng xuất</button> {/* TS biết logout là hàm async và không cần tham số */}
</div>
);
}
export default UserProfile;
- Giải thích:
- Khi gọi
useAuth()
, TypeScript biết rằng object trả về có kiểuAuthContextType
và chứa các thuộc tínhuser
,isLoading
,error
,login
,logout
với các kiểu đã định nghĩa. - Khi kiểm tra
if (!user)
, trong blockif
tiếp theo, TypeScript biết rằnguser
chắc chắn là kiểuUser
(không phảinull
) và cho phép bạn truy cập các thuộc tính của nó (user.username
,user.email
) một cách an toàn mà không cần kiểm tra thêm (nhưuser && user.username
). - Bạn có thể tự tin gọi
logout()
vì TypeScript đảm bảo nó là một hàm tồn tại và không yêu cầu tham số.
- Khi gọi
Lợi Ích To Lớn
Việc dành thời gian typing cho logic xác thực tùy chỉnh mang lại những lợi ích không nhỏ:
- Giảm thiểu lỗi runtime: Hầu hết các lỗi liên quan đến truy cập thuộc tính không tồn tại hoặc sai kiểu dữ liệu sẽ bị bắt bởi TypeScript khi biên dịch.
- Cải thiện khả năng đọc code: Khi nhìn vào một hàm hoặc một object, bạn biết ngay nó mong đợi gì và sẽ trả về gì nhờ các định nghĩa kiểu rõ ràng.
- Tăng tốc độ phát triển: Với Intellisense/Autocompletion được hỗ trợ bởi TypeScript, bạn có thể thấy ngay các thuộc tính và phương thức có sẵn trên object, giúp viết code nhanh hơn và chính xác hơn.
- Dễ dàng bảo trì và refactor: Khi cần thay đổi cấu trúc dữ liệu người dùng hoặc chữ ký hàm, TypeScript sẽ chỉ ra tất cả những nơi trong code bị ảnh hưởng, giúp bạn cập nhật code một cách an toàn.
- Tăng cường sự tự tin: Bạn có thể code với sự tự tin cao hơn khi biết rằng trình biên dịch đã kiểm tra tính đúng đắn về kiểu dữ liệu cho bạn.
Những Điểm Cần Lưu Ý Thêm
- Typing phản hồi API: Hãy cố gắng định nghĩa kiểu cho phản hồi từ các API liên quan đến xác thực. Điều này giúp bạn chắc chắn về dữ liệu bạn nhận được và gán vào state hoặc object
User
. Các thư viện nhưzod
hoặcio-ts
có thể giúp bạn xác thực dữ liệu nhận được từ API tại runtime (runtime validation) kết hợp với typing (static typing). - Typing lưu trữ cục bộ (localStorage, cookies): Nếu bạn lưu trữ token hoặc thông tin người dùng ở localStorage/cookies, hãy cẩn thận với kiểu dữ liệu. Dữ liệu từ storage luôn là string, bạn cần parse (JSON.parse) và có thể cần thêm bước xác thực để đảm bảo cấu trúc đúng trước khi sử dụng.
- Error Handling: Định kiểu cho cả các đối tượng lỗi (
Error | null
hoặc một interface lỗi tùy chỉnh) giúp bạn xử lý lỗi một cách nhất quán và an toàn.
Comments