Phỏng Vấn Fullstack Senior: Auth & Security với Session, JWT, CSRF, XSS và OAuth2
Bài này có bản tiếng Anh. Read in English →
Nguyen Hai Trinh • Senior Software Engineer @ SmileViet
Ai cũng từng làm màn hình đăng nhập, nên ai cũng nghĩ mình biết Auth. Mà đúng chỗ đó lại phân loại ứng viên rất tốt ở vòng Fullstack Senior. Người chỉ biết “dùng JWT, lưu localStorage” trả lời một kiểu. Người hiểu mô hình đe doạ (threat model) trả lời kiểu khác hẳn.
Dưới đây là bảy câu hỏi mình hay gặp, giữ nguyên lối cũ: câu hỏi, câu trả lời yếu, câu trả lời mạnh, rồi follow-up bẫy. Backend Go, frontend React/TypeScript. Mấy chuyện kể ở đây gom từ nhiều buổi, không gắn với công ty nào cụ thể.
1. “Session hay JWT?”
Câu trả lời yếu: “JWT hiện đại hơn, stateless nên scale tốt hơn.” Vừa dứt câu là interviewer chuẩn bị đào sâu.
Câu trả lời mạnh: hai thứ này giải quyết hai bài toán khác nhau một chút.
- Session ID (opaque token): một chuỗi ngẫu nhiên không mang ý nghĩa, server lưu trạng thái (Redis, DB) ánh xạ ID sang user. Ưu điểm lớn: thu hồi tức thì, xoá bản ghi là xong; đổi quyền có hiệu lực ngay; kích thước nhỏ. Nhược điểm: mỗi request phải tra store. Thường chỉ là một lần đọc Redis, rẻ hơn nhiều người tưởng.
- JWT (self-contained): server ký claims, về sau chỉ cần verify chữ ký, không cần tra store. Ưu điểm: các service khác trong hệ thống tự verify được mà không gọi auth service. Nhược điểm: không thu hồi được trước khi hết hạn nếu không dựng thêm denylist. Mà denylist lại chính là state, tức là bạn quay về gần với session.
Người có nghề thường nói thẳng: một ứng dụng web với một backend thì session cookie đơn giản và an toàn hơn. JWT hợp với truyền danh tính giữa service (service-to-service), hoặc access token ngắn hạn (5-15 phút) trong kiến trúc nhiều service, kết hợp refresh token để duy trì phiên.
Dùng JWT thì phải verify cho đúng. Hai lỗi kinh điển: chấp nhận alg: none, và nhầm lẫn thuật toán — kẻ tấn công đổi RS256 thành HS256 rồi dùng public key làm secret. Phía server luôn phải pin thuật toán:
type Claims struct {
Role string `json:"role"`
jwt.RegisteredClaims
}
func parseAccessToken(raw string, key []byte) (*Claims, error) {
claims := &Claims{}
tok, err := jwt.ParseWithClaims(
raw, claims,
func(t *jwt.Token) (any, error) { return key, nil },
jwt.WithValidMethods([]string{"HS256"}), // chỉ chấp nhận đúng thuật toán đã chọn
jwt.WithIssuer("api.example.com"),
jwt.WithAudience("web"),
jwt.WithExpirationRequired(),
)
if err != nil || !tok.Valid {
return nil, errors.New("invalid token")
}
return claims, nil
}
Thư viện ở đây là github.com/golang-jwt/jwt/v5. Đừng nhét dữ liệu nhạy cảm vào payload. JWT được ký, không được mã hoá. Ai cầm token cũng đọc được claims bằng base64 decode.
Follow-up bẫy: “Muốn logout ngay lập tức với JWT thì sao?” Trả lời thật: không có cách nào miễn phí. Vài lựa chọn — access token thật ngắn hạn cộng thu hồi refresh token, chấp nhận cửa sổ vài phút; denylist theo jti trong Redis; hoặc token_version theo user, so sánh mỗi request. Hai cái sau đều có state. Người senior nói thẳng chuyện đó thay vì giữ mãi nhãn “stateless”.
2. Refresh token rotation và cái bẫy hai tab
Interviewer hỏi: “Vì sao cần refresh token? Nếu refresh token bị lộ thì sao?”
Câu trả lời yếu: “Refresh token sống lâu hơn để user khỏi đăng nhập lại.” Đúng một nửa. Phần bị bỏ qua mới là phần hay: phát hiện đánh cắp.
Câu trả lời mạnh: tách hai vai trò. Access token ngắn hạn, gửi mỗi request, nên rủi ro lộ cao nhưng thiệt hại có hạn. Refresh token dài hạn, chỉ gửi tới endpoint refresh, nên bề mặt lộ nhỏ hơn nhiều. Rotation nghĩa là mỗi lần refresh thành công, server cấp refresh token mới và vô hiệu hoá cái cũ. Giá trị thật nằm ở reuse detection. Một refresh token đã dùng mà bị trình lại thì hoặc kẻ trộm, hoặc user thật đang giữ bản sao cũ — không biết ai là ai. Nên xử lý: thu hồi cả family, tức chuỗi token sinh ra từ một lần đăng nhập, và buộc đăng nhập lại.
Khi triển khai, lưu hash của refresh token. SHA-256 là đủ, vì token ngẫu nhiên 256 bit nên không cần bcrypt. Và đánh dấu “đã dùng” phải nguyên tử, để hai request đồng thời không cùng thắng.
var ErrReuse = errors.New("refresh token reuse detected")
type RefreshRecord struct {
ID string
FamilyID string
UserID string
Used bool
Revoked bool
ExpiresAt time.Time
}
type RefreshStore interface {
FindByHash(ctx context.Context, hash string) (*RefreshRecord, error)
// UPDATE ... SET used = true WHERE id = $1 AND used = false
// trả về true nếu có đúng một dòng bị đổi
MarkUsed(ctx context.Context, id string) (bool, error)
RevokeFamily(ctx context.Context, familyID string) error
Insert(ctx context.Context, rec RefreshRecord, hash string) error
}
func hashToken(t string) string {
sum := sha256.Sum256([]byte(t))
return hex.EncodeToString(sum[:])
}
func newToken() (string, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(b), nil
}
func (s *AuthService) Rotate(ctx context.Context, presented string) (access, refresh string, err error) {
rec, err := s.store.FindByHash(ctx, hashToken(presented))
if err != nil || rec == nil || rec.Revoked || time.Now().After(rec.ExpiresAt) {
return "", "", errors.New("invalid refresh token")
}
ok, err := s.store.MarkUsed(ctx, rec.ID)
if err != nil {
return "", "", err
}
if !ok { // đã bị dùng rồi: có thể là trộm, thu hồi cả family
_ = s.store.RevokeFamily(ctx, rec.FamilyID)
return "", "", ErrReuse
}
refresh, err = newToken()
if err != nil {
return "", "", err
}
next := RefreshRecord{
ID: uuid.NewString(),
FamilyID: rec.FamilyID, // cùng family
UserID: rec.UserID,
ExpiresAt: time.Now().Add(30 * 24 * time.Hour),
}
if err = s.store.Insert(ctx, next, hashToken(refresh)); err != nil {
return "", "", err
}
access, err = s.issueAccessToken(rec.UserID, 10*time.Minute)
return access, refresh, err
}
Follow-up bẫy: “Hai tab cùng refresh một lúc thì sao? Bạn có tự khoá user ra không?” Vấn đề thật đấy. Hai request cùng mang một refresh token, một cái thắng, cái kia bị coi là reuse và cả family bị huỷ. Giảm thì giảm thế này: phía client dùng single-flight để chỉ một refresh chạy tại một thời điểm; phía server có thể thêm grace window ngắn vài giây, cho token vừa dùng được trình lại mà không kích hoạt thu hồi. Đánh đổi giữa an toàn và trải nghiệm. Người senior nêu cả hai phía.
let inflight: Promise<boolean> | null = null;
export function refreshOnce(): Promise<boolean> {
inflight ??= fetch("/auth/refresh", { method: "POST", credentials: "include" })
.then((r) => r.ok)
.finally(() => { inflight = null; });
return inflight;
}
export async function apiFetch(input: RequestInfo, init: RequestInit = {}) {
const opts = { ...init, credentials: "include" as const };
let res = await fetch(input, opts);
if (res.status === 401 && (await refreshOnce())) {
res = await fetch(input, opts); // thử lại đúng một lần
}
return res;
}
3. Cookie flags: HttpOnly, Secure, SameSite — và chỗ ai cũng nhầm
Interviewer hỏi: “Token lưu ở đâu: localStorage hay cookie?”
Câu trả lời yếu: “Cookie an toàn hơn.” Hết. Không có lý do nào đi kèm.
Câu trả lời mạnh: mỗi nơi lưu chống được một loại tấn công và dính một loại khác.
- localStorage/sessionStorage: JavaScript đọc được, nên bất kỳ XSS nào cũng đánh cắp được token rồi gửi đi chỗ khác. Bù lại, nó không tự động gắn vào request, nên không dính CSRF.
- Cookie
HttpOnly: JS không đọc đượcdocument.cookie, nên XSS không lấy cắp được giá trị token. Nhưng trình duyệt tự gắn cookie vào request, nên dính CSRF nếu không phòng thủ. Lưu ý: XSS vẫn dùng được phiên — gửi request ngay từ trang bị tấn công.HttpOnlychỉ ngăn việc mang token đi.
Ba flag cần nhớ:
HttpOnly: chặn JS đọc cookie.Secure: chỉ gửi qua HTTPS.SameSite:Strict(không gửi trong request cross-site, kể cả khi bấm link từ site khác),Lax(gửi khi điều hướng top-level bằng GET, mặc định của Chrome hiện nay khi không khai báo),None(luôn gửi, bắt buộc kèmSecure).
Thêm tiền tố __Host- để trình duyệt tự ép: cookie phải Secure, Path=/, và không được có thuộc tính Domain. Nghĩa là subdomain khác không ghi đè được.
func setSession(w http.ResponseWriter, sid string) {
http.SetCookie(w, &http.Cookie{
Name: "__Host-sid",
Value: sid,
Path: "/",
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteLaxMode,
MaxAge: int((8 * time.Hour).Seconds()),
})
}
Follow-up bẫy: “SameSite=Lax đã đủ chống CSRF chưa?” Đừng dựa riêng vào nó. Lax vẫn gửi cookie cho GET điều hướng top-level, nên GET tuyệt đối không được đổi trạng thái. Một số trình duyệt cũ không hỗ trợ. Và “same-site” tính theo site (eTLD+1), không phải origin — subdomain bị chiếm quyền (subdomain takeover) vẫn là same-site với bạn. Nên mới cần lớp thứ hai ở mục sau.
4. CSRF: trình duyệt tự gắn cookie, đó là gốc vấn đề
Interviewer hỏi: “CSRF hoạt động thế nào và chống ra sao?”
Câu trả lời yếu: “Dùng CSRF token.” Đúng, mà chưa đủ. Không giải thích được vì sao token hiệu quả, cũng không kể được phương án nào khác.
Câu trả lời mạnh: CSRF khai thác đúng một thứ: trình duyệt tự gắn cookie vào mọi request tới domain đích, kể cả request xuất phát từ trang của kẻ tấn công — ví dụ một form ẩn tự submit tới bank.example/transfer. Server không phân biệt được request do người dùng chủ động hay bị lừa. Hai điều kiện tiên quyết: xác thực dựa vào cookie, và endpoint đổi trạng thái chấp nhận loại request mà trang khác tạo được, tức form POST.
Các lớp phòng thủ, có thể kết hợp:
SameSite=Lax/Strictlàm hàng rào đầu tiên.- Kiểm tra
Origin(hoặcReferer) trên mọi request đổi trạng thái. Trình duyệt hiện đại luôn gửiOrigincho POST cross-origin, và kẻ tấn công không giả mạo được header này từ JS của trang khác. HeaderSec-Fetch-Sitecũng hữu ích. - Synchronizer token hoặc double-submit cookie: client phải gửi một giá trị bí mật mà trang tấn công không đọc được, qua header tuỳ chỉnh. Header tuỳ chỉnh còn buộc request qua CORS preflight, mà trang lạ thì không qua được nếu server không cho phép.
- Không bao giờ đổi trạng thái bằng GET.
func CSRF(allowedOrigin string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet, http.MethodHead, http.MethodOptions:
next.ServeHTTP(w, r)
return
}
if r.Header.Get("Origin") != allowedOrigin {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
if r.Header.Get("X-Requested-With") != "fetch" { // header tuỳ chỉnh ép preflight
http.Error(w, "forbidden", http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
}
Request thiếu Origin — client cũ, hoặc công cụ không phải trình duyệt — sẽ bị middleware trên từ chối. Đó là mặc định an toàn cho API chỉ phục vụ trình duyệt.
Follow-up bẫy: “API dùng JWT trong header Authorization có cần CSRF không?” Nguyên tắc thì không, vì trình duyệt không tự gắn header đó. Nhưng JWT nằm trong cookie thì vẫn cần. CSRF phụ thuộc cách credential được đính kèm, không phụ thuộc định dạng token.
5. XSS và CSP: escape thôi chưa đủ
Interviewer hỏi: “XSS có mấy loại? Chống thế nào ngoài việc escape?”
Câu trả lời yếu: “Escape input của user.” Thiếu hai thứ: escape ở đâu, và nếu lọt thì sao.
Câu trả lời mạnh: ba loại. Stored — payload lưu trong DB, hiện ra cho người khác. Reflected — payload từ URL phản chiếu vào trang. DOM-based — JS phía client ghi dữ liệu không tin cậy vào sink nguy hiểm như innerHTML, document.write, eval. Nguyên tắc duy nhất cần nhớ: escape theo ngữ cảnh đầu ra. HTML body, attribute, URL, JS, CSS mỗi nơi một kiểu. Đừng escape “một lần lúc nhập”. React escape text mặc định, nhưng vẫn có cửa thoát:
// Nguy hiểm: ghi HTML thô
<div dangerouslySetInnerHTML={{ __html: comment.html }} />
// Nếu bắt buộc phải render HTML do người dùng tạo, sanitize bằng thư viện đã kiểm chứng
import DOMPurify from "dompurify";
<div dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(comment.html) }} />
// Cũng nguy hiểm: href từ dữ liệu người dùng có thể là "javascript:..."
<a href={user.website}>Website</a>
Với href thì parse bằng new URL() và chỉ cho phép http:/https:.
Lớp phòng thủ chiều sâu là Content Security Policy (CSP): header nói với trình duyệt nguồn script nào được chạy. Policy nên chặt, dựa trên nonce thay vì whitelist domain. Whitelist dễ bị bypass qua JSONP, hoặc qua một CDN có thư viện cho phép chạy code tuỳ ý:
func CSP(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
b := make([]byte, 16)
_, _ = rand.Read(b)
nonce := base64.StdEncoding.EncodeToString(b) // mỗi response một nonce mới
w.Header().Set("Content-Security-Policy", strings.Join([]string{
"default-src 'self'",
"script-src 'self' 'nonce-" + nonce + "' 'strict-dynamic'",
"object-src 'none'",
"base-uri 'none'",
"frame-ancestors 'none'",
}, "; "))
ctx := context.WithValue(r.Context(), nonceKey{}, nonce) // template lấy nonce ra gắn vào <script nonce="...">
next.ServeHTTP(w, r.WithContext(ctx))
})
}
type nonceKey struct{} cần khai báo ở cùng package. Nonce phải ngẫu nhiên, không đoán được, và đổi mỗi response — nonce cố định thì vô nghĩa. Hai directive object-src 'none' và base-uri 'none' chặn hai đường tấn công hay bị quên. Triển khai bằng Content-Security-Policy-Report-Only trước, để xem cái gì sẽ vỡ.
Follow-up bẫy: “CSP có thay thế được sanitize không?” Không. CSP giảm thiệt hại khi XSS lọt qua, chứ không loại bỏ lỗ hổng. HttpOnly cộng CSP cũng không ngăn kẻ tấn công thực hiện hành động thay mặt user ngay trong trang. Nhiều lớp nghĩa là mỗi lớp giả định lớp trước đã thủng.
6. OAuth2, OIDC và PKCE: cái nào làm việc gì
Interviewer hỏi: “OAuth2 và OIDC khác nhau thế nào? PKCE giải quyết gì?”
Câu trả lời yếu: “OAuth2 để đăng nhập bằng Google.” Nhầm kinh điển.
Câu trả lời mạnh: OAuth2 là giao thức uỷ quyền (authorization). Ứng dụng nhận access token để gọi API thay mặt user, và giao thức này không nói gì về việc user là ai. OIDC (OpenID Connect) là lớp danh tính (authentication) xây trên OAuth2: thêm ID token — một JWT chứa sub, iss, aud, exp, nonce — cùng endpoint userinfo và scope openid. “Đăng nhập bằng Google” là OIDC.
Luồng chuẩn cho ứng dụng web và SPA hiện nay là Authorization Code + PKCE. PKCE sinh ra để chặn một chuyện: authorization code đi qua redirect (URL), nên có thể bị chặn — app độc hại, log, history. Không có PKCE thì ai chặn được code đều đổi được token. Có PKCE, client sinh code_verifier ngẫu nhiên, gửi code_challenge = BASE64URL(SHA256(verifier)) khi bắt đầu, rồi gửi verifier thật khi đổi code lấy token. Chặn được code mà không có verifier thì vô dụng. PKCE được khuyến nghị cho mọi loại client, kể cả client bí mật. Implicit flow — token trả thẳng trên URL — đã bị loại bỏ vì lý do này.
Còn hai tham số nữa hay bị quên. state chống CSRF trên chính luồng đăng nhập (login CSRF). nonce ràng buộc ID token với phiên đăng nhập vừa khởi tạo, chống replay.
// Bắt đầu đăng nhập
func (h *Handler) Login(w http.ResponseWriter, r *http.Request) {
state, _ := newToken()
nonce, _ := newToken()
verifier := oauth2.GenerateVerifier() // golang.org/x/oauth2 >= v0.13
h.pending.Save(state, pendingAuth{Verifier: verifier, Nonce: nonce}) // gắn với phiên trình duyệt, TTL ngắn
url := h.oauth.AuthCodeURL(state,
oauth2.S256ChallengeOption(verifier),
oidc.Nonce(nonce),
)
http.Redirect(w, r, url, http.StatusFound)
}
// Callback
func (h *Handler) Callback(w http.ResponseWriter, r *http.Request) {
p, ok := h.pending.Take(r.URL.Query().Get("state")) // lấy và xoá: dùng một lần
if !ok {
http.Error(w, "invalid state", http.StatusBadRequest)
return
}
tok, err := h.oauth.Exchange(r.Context(), r.URL.Query().Get("code"),
oauth2.VerifierOption(p.Verifier))
if err != nil {
http.Error(w, "exchange failed", http.StatusUnauthorized)
return
}
rawID, _ := tok.Extra("id_token").(string)
idToken, err := h.verifier.Verify(r.Context(), rawID) // kiểm tra chữ ký, iss, aud, exp
if err != nil || idToken.Nonce != p.Nonce {
http.Error(w, "invalid id token", http.StatusUnauthorized)
return
}
var claims struct {
Email string `json:"email"`
}
_ = idToken.Claims(&claims)
h.startSession(w, r, idToken.Subject, claims.Email, tok)
}
h.verifier được tạo bằng provider.Verifier(&oidc.Config{ClientID: clientID}) từ github.com/coreos/go-oidc/v3/oidc, còn oidc.Nonce là option của gói đó. Định danh người dùng nên dùng cặp (iss, sub) chứ đừng dùng email — email đổi được, và tái sử dụng được.
Follow-up bẫy: “Vì sao SPA không nên tự giữ access token trong localStorage?” Câu này dẫn thẳng sang phần cuối.
7. BFF pattern: để token ra ngoài tầm với của JS
Interviewer hỏi: “Với SPA gọi nhiều API, bạn thiết kế auth thế nào cho an toàn?”
Câu trả lời yếu: “Lấy JWT từ provider rồi lưu vào localStorage, gắn vào header.” Mọi XSS thành chiếm quyền tài khoản, và refresh token dài hạn nằm ngay trong tầm tay của JS.
Câu trả lời mạnh: dùng Backend For Frontend (BFF). Một server nhỏ, cùng site với SPA, đóng vai confidential client của OAuth2. SPA không bao giờ thấy access/refresh token. Luồng đi thế này:
- SPA điều hướng tới
/auth/logincủa BFF; BFF thực hiện Authorization Code + PKCE (mục 6) với identity provider. - BFF giữ access và refresh token phía server, gắn với một session ID.
- Trình duyệt chỉ nhận cookie
__Host-sid(HttpOnly,Secure,SameSite). - SPA gọi
/api/*trên chính BFF; BFF tra session, lấy access token, tự refresh nếu hết hạn, gắn vào request rồi proxy tới API thật.
Bốn bước đó, nhìn theo thời gian thì ra thế này:
sequenceDiagram participant B as Trình duyệt (React) participant F as BFF (Go) participant I as Identity Provider participant A as API nội bộ B->>F: GET /auth/login F->>I: redirect (code_challenge, state, nonce) I->>F: callback ?code=... F->>I: đổi code + code_verifier F-->>B: Set-Cookie __Host-sid (HttpOnly) B->>F: GET /api/orders (cookie) F->>A: Authorization: Bearer access_token A-->>F: dữ liệu F-->>B: dữ liệu
func (h *Handler) Proxy(upstream *url.URL) http.Handler {
rp := httputil.NewSingleHostReverseProxy(upstream)
base := rp.Director
rp.Director = func(r *http.Request) {
base(r)
sess := sessionFrom(r.Context()) // middleware đã nạp session từ cookie
r.Header.Del("Cookie") // không rò cookie của BFF xuống upstream
r.Header.Set("Authorization", "Bearer "+sess.AccessToken)
}
return http.StripPrefix("/api", rp)
}
Phía React thì không còn logic token nào cả:
export async function getMe() {
const res = await fetch("/api/me", {
credentials: "include",
headers: { "X-Requested-With": "fetch" },
});
if (res.status === 401) {
window.location.assign("/auth/login"); // để BFF dẫn đi đăng nhập
return null;
}
return res.json();
}
Vì dùng cookie nên BFF bắt buộc phải có chống CSRF, tức middleware ở mục 4. Đổi lại, bạn loại được cả một lớp rủi ro: token không còn nằm trong JS để XSS lấy cắp. Chi phí thì có. Thêm một thành phần phải vận hành, thêm session store, thêm độ trễ của một hop nữa. Hệ thống nhỏ thì session cookie truyền thống từ chính backend là đủ. BFF đáng giá khi phía sau có nhiều API, hoặc phải tích hợp một identity provider bên ngoài.
Follow-up bẫy: “BFF có chống được XSS không?” Không. Nó chống đánh cắp token, nhưng script độc vẫn gọi được /api/* bằng cookie của user khi trang đang mở. Nên CSP và sanitize vẫn bắt buộc. Câu trả lời đúng luôn là “giảm thiệt hại”, không phải “miễn nhiễm”.
Mình chấm phần này thế nào
Ba dấu hiệu phân biệt người làm thật với người thuộc bài. Một, họ bắt đầu từ mô hình đe doạ: ai là kẻ tấn công, họ làm được gì — chạy script trong trang, dụ user bấm link, chặn redirect. Hai, họ nói được đánh đổi của từng lựa chọn thay vì gắn nhãn tốt/xấu. JWT không “xấu”, chỉ là thu hồi khó. localStorage không “cấm”, chỉ là rủi ro XSS. Ba, họ nghĩ theo nhiều lớp. Cookie flags, kiểm tra Origin, CSP, sanitize, rotation đều có thể lỗi riêng lẻ, nhưng cùng nhau thì kẻ tấn công phải thắng nhiều lần.
Bài tập mình hay gợi ý: dựng một BFF nhỏ bằng Go đứng trước một SPA React, đăng nhập qua một OIDC provider thử nghiệm với PKCE, rồi tự viết một trang “tấn công” để thử CSRF vào chính nó. Tự phá được nó thì mới hiểu từng lớp đang chặn cái gì.
Đọc tiếp
Cần người cho dự án contract hoặc freelance?
Mình nhận dự án fullstack — backend Go, frontend React/Next.js, hoặc cả hai đầu. Gửi vài dòng về phạm vi công việc và thời gian, mình phản hồi trong 48 giờ.