Xác thực và Phân quyền
Xem thử"Bạn là ai" và "bạn được làm gì" là hai câu hỏi khác nhau
Một nhân viên chăm sóc khách hàng của gdev-shop đăng nhập thành công vào trang quản trị. Hệ thống đã biết chắc anh là ai. Nhưng khi anh mở trang "Hoàn tiền", hệ thống từ chối: anh chỉ có quyền xem đơn, không có quyền hoàn tiền. Cùng một người, hai câu hỏi, hai cơ chế:
- Authentication (xác thực): bạn là ai? Chứng minh danh tính.
- Authorization (phân quyền): bạn được làm gì? Xác định quyền hạn của danh tính đã biết.
Hai thứ này tách biệt. Xác thực thành công không có nghĩa được làm mọi việc. Nhầm lẫn giữa hai khái niệm là nguồn của rất nhiều lỗ hổng. Mã trạng thái HTTP phản ánh sự phân biệt: 401 Unauthorized thực chất nghĩa là "chưa xác thực được bạn", còn 403 Forbidden nghĩa là "tôi biết bạn là ai nhưng bạn không được phép".
Xác thực
Người dùng chứng minh danh tính bằng gì?
Ba loại bằng chứng (gọi là yếu tố xác thực):
- Điều bạn biết: mật khẩu, mã PIN.
- Điều bạn có: điện thoại nhận mã OTP, khóa phần cứng, ứng dụng xác thực.
- Điều bạn là: vân tay, khuôn mặt.
MFA (Multi-Factor Authentication, xác thực đa yếu tố) yêu cầu từ hai loại trở lên, nên kẻ tấn công đánh cắp được mật khẩu vẫn chưa vào được. Đây là một trong những biện pháp hiệu quả nhất để giảm chiếm đoạt tài khoản.
Lưu mật khẩu đúng cách
Quy tắc số một: không bao giờ lưu mật khẩu dạng thô, và không dùng mã hóa hai chiều. Lưu giá trị băm (hash) của mật khẩu bằng một thuật toán băm chậm có chủ đích, cùng một salt (muối) ngẫu nhiên cho mỗi người dùng:
- Băm một chiều: từ hash không thể suy ngược ra mật khẩu. Khi đăng nhập, hệ thống băm mật khẩu nhập vào và so với hash đã lưu.
- Salt: chuỗi ngẫu nhiên riêng của từng người, trộn với mật khẩu trước khi băm. Hai người dùng cùng mật khẩu
123456có hai hash khác nhau, và kẻ tấn công không thể dùng bảng băm dựng sẵn. - Thuật toán chậm: dùng thuật toán thiết kế riêng cho mật khẩu như Argon2, bcrypt, scrypt. Chúng cố ý tốn thời gian (ví dụ 100 ms mỗi lần băm) nên kẻ tấn công có được cơ sở dữ liệu hash cũng chỉ thử được rất ít mật khẩu mỗi giây. Không dùng hàm băm nhanh thông thường (như SHA-256 đơn thuần) cho mật khẩu.
String hash = passwordEncoder.encode(rawPassword);
boolean matches = passwordEncoder.matches(rawPassword, hash);
Hai dòng này minh họa ý tưởng (như thư viện PasswordEncoder với bcrypt hoặc Argon2 của Spring Security): encode băm kèm salt tự động, matches kiểm tra mật khẩu nhập vào với hash đã lưu. Salt nằm ngay trong chuỗi hash nên không cần lưu riêng.
Luồng đăng nhập
Đang tải Animation…
Đang tải Animation…
Đang tải sơ đồ…
Đang tải Animation…
Cách đọc sơ đồ
- Client gửi email và mật khẩu qua HTTPS (bắt buộc, nếu không mật khẩu bị nghe lén).
- API tìm người dùng và lấy hash đã lưu.
- API băm mật khẩu nhập vào và so sánh với hash. Không bao giờ có bước nào "giải mã" mật khẩu.
- Đúng thì API cấp một chứng nhận phiên: cookie phiên (lưu ở server, ví dụ trong Redis) hoặc token ký số (đã học ở lesson Stateful và Sticky). Sai thì trả lỗi, và nên giới hạn số lần thử theo tài khoản và theo IP để chống dò mật khẩu.
- Các request sau mang chứng nhận đó. API kiểm tra chứng nhận và xác định người dùng mà không cần mật khẩu nữa.
Hai chi tiết thiết kế: thông báo lỗi nên không tiết lộ email có tồn tại không ("email hoặc mật khẩu không đúng" thay vì "mật khẩu sai"), và cookie phiên cần các cờ HttpOnly, Secure, SameSite đã nhắc ở bài trước.
Đăng nhập qua bên thứ ba: OAuth 2.0 và OpenID Connect
Thay vì tự quản lý mật khẩu, nhiều hệ thống cho phép "Đăng nhập bằng Google". Hai chuẩn liên quan, hay bị nhầm:
- OAuth 2.0 là chuẩn ủy quyền truy cập: cho một ứng dụng quyền truy cập hạn chế vào tài nguyên của bạn mà không đưa mật khẩu. Ví dụ: cho ứng dụng lịch được đọc danh bạ Google.
- OpenID Connect (OIDC) là lớp xác thực xây trên OAuth 2.0: cho ứng dụng biết người dùng là ai, qua một ID token.
Lợi ích: bạn không phải lưu mật khẩu của người dùng, và tận dụng bảo mật (MFA) của nhà cung cấp danh tính. Cái giá: phụ thuộc vào bên thứ ba, và cần triển khai đúng luồng (sai sót ở đây gây lỗ hổng nghiêm trọng, nên dùng thư viện đã kiểm chứng).
Phân quyền
Sau khi biết bạn là ai, hệ thống phải quyết định với mỗi thao tác, bạn có được phép không.
Các mô hình phổ biến
- RBAC (Role-Based Access Control, theo vai trò): gán người dùng vào vai trò (khách, nhân viên hỗ trợ, quản trị), mỗi vai trò có tập quyền. Đơn giản, dễ hiểu. Hạn chế: khó diễn tả quy tắc theo ngữ cảnh.
- ABAC (Attribute-Based Access Control, theo thuộc tính): quyết định dựa trên thuộc tính của người dùng, tài nguyên và hoàn cảnh (ví dụ "nhân viên chỉ hoàn tiền dưới 1 triệu và trong giờ làm việc"). Linh hoạt hơn, phức tạp hơn.
- Kiểm tra quyền sở hữu: một dạng cụ thể rất hay gặp: khách chỉ được xem đơn của chính họ.
| Câu hỏi | Mô hình phù hợp |
|---|---|
| Nhân viên hỗ trợ có được xem đơn không? | RBAC (vai trò support có quyền orders:read) |
| Khách A có được xem đơn 43 không? | Kiểm tra quyền sở hữu (đơn 43 thuộc khách nào?) |
| Nhân viên được hoàn tiền tối đa bao nhiêu? | ABAC (phụ thuộc số tiền, vai trò, thời gian) |
Lỗi phân quyền kinh điển: IDOR
Giả sử endpoint trả đơn hàng như sau, chỉ kiểm tra đã đăng nhập:
@GetMapping("/orders/{id}")
public Order getOrder(@PathVariable long id) {
return orderRepository.findById(id).orElseThrow();
}
Bất kỳ người đã đăng nhập nào cũng xem được mọi đơn bằng cách đổi id trong URL. Đây là IDOR (Insecure Direct Object Reference), thuộc nhóm lỗi phân quyền bị phá vỡ, vẫn là một trong những lỗi phổ biến nhất. Cách sửa: luôn kiểm tra quyền sở hữu của tài nguyên với người dùng hiện tại:
@GetMapping("/orders/{id}")
public Order getOrder(@PathVariable long id, @AuthenticationPrincipal CurrentUser user) {
Order order = orderRepository.findById(id).orElseThrow(NotFoundException::new);
if (order.customerId() != user.id() && !user.hasRole("SUPPORT")) {
throw new ForbiddenException();
}
return order;
}
Phiên bản sửa kiểm tra: đơn thuộc người dùng này, hoặc người dùng có vai trò SUPPORT. Nếu không, trả 403. Một lựa chọn khác dùng nhiều nơi: trả 404 thay vì 403 để không tiết lộ đơn đó tồn tại. Cả hai đều chấp nhận được, miễn là nhất quán và có chủ đích.
Nơi đặt kiểm tra quyền
- Phía server, ở mọi điểm truy cập dữ liệu: không bao giờ chỉ dựa vào việc ẩn nút trên giao diện. Kẻ tấn công không dùng giao diện của bạn.
- Gần dữ liệu và nghiệp vụ: tập trung ở một lớp rõ ràng (thư viện hoặc service dùng chung) để không bỏ sót ở một endpoint.
- Gateway có thể xác thực và kiểm tra quyền thô (có token hợp lệ không, có được gọi API này không). Quyền chi tiết (đơn này có của người này không) vẫn phải nằm ở service giữ dữ liệu.
- Mặc định từ chối (deny by default): không có quy tắc cho phép rõ ràng thì từ chối.
Những sai lầm thường gặp
- Chỉ xác thực mà không phân quyền: đăng nhập được là xem được mọi thứ (IDOR).
- Phân quyền chỉ ở giao diện: ẩn nút nhưng API vẫn mở.
- Lưu mật khẩu thô, hoặc dùng hàm băm nhanh không salt.
- Tin vào dữ liệu do client gửi về danh tính: ví dụ nhận
userIdtừ body request thay vì lấy từ chứng nhận phiên đã xác thực. - Quyền quá rộng: mọi nhân viên đều là quản trị cho tiện.
- Không giới hạn số lần đăng nhập thất bại: cho phép dò mật khẩu hàng loạt.
Những hiểu lầm phổ biến
- "Đăng nhập rồi là được phép." Đăng nhập chỉ chứng minh danh tính. Mỗi thao tác cần kiểm tra quyền.
- "401 và 403 như nhau." 401 là chưa xác thực, 403 là đã xác thực nhưng không được phép.
- "OAuth là đăng nhập." OAuth 2.0 là ủy quyền truy cập. Đăng nhập (xác thực) là OpenID Connect xây trên nó.
- "Mã hóa mật khẩu là cách bảo vệ tốt." Mã hóa hai chiều có thể giải ngược nếu lộ khóa. Cần băm một chiều chậm kèm salt.
Must Know, Good to Know, Advanced
- Must Know: phân biệt xác thực và phân quyền; 401 và 403; MFA; băm mật khẩu chậm kèm salt; luồng đăng nhập; kiểm tra quyền sở hữu ở server; IDOR và cách sửa; mặc định từ chối.
- Good to Know: RBAC và ABAC; OAuth 2.0 và OIDC; giới hạn đăng nhập thất bại; không tiết lộ email có tồn tại.
- Advanced: passkey và WebAuthn, quản lý khóa và xoay vòng, policy engine. Chỉ cần biết tên và mục đích.
Tự kiểm tra
- Hai khách cùng mật khẩu
123456nhưng hash khác nhau trong database. Cơ chế nào tạo ra điều này và lợi ích gì? - Endpoint
GET /orders/{id}chỉ kiểm tra đã đăng nhập. Mô tả cách kẻ tấn công khai thác và cách sửa. - Vì sao việc ẩn nút "Hoàn tiền" trên giao diện không đủ để bảo vệ chức năng đó?
Đáp án gợi ý
- Mỗi mật khẩu được ghép với một salt ngẫu nhiên riêng trước khi băm, nên cùng
123456cho hai hash khác nhau. Lợi ích: kẻ tấn công không thể dùng bảng băm dựng sẵn (rainbow table) hay nhìn hash giống nhau để biết hai người cùng mật khẩu, và phải bẻ từng tài khoản riêng. - Chỉ kiểm tra đã đăng nhập nên kẻ tấn công đổi
{id}trong URL để xem đơn của người khác (IDOR), có thể tự động lặp qua các giá trị. Sửa: sau khi tải đơn, kiểm tra đơn thuộcuserIdcủa người dùng đang đăng nhập (hoặc quyền phù hợp), nếu không thì trả 403 hoặc 404, và mặc định từ chối. - Giao diện chạy ở phía client, kẻ tấn công có thể gọi thẳng API (curl, công cụ) mà không qua nút. Ẩn nút chỉ cải thiện trải nghiệm, không phải kiểm soát an ninh. Quyền phải được kiểm tra lại ở server cho mọi yêu cầu.
Nếu chỉ nhớ 3 điều
- Xác thực là bạn là ai, phân quyền là bạn được làm gì; 401 là chưa xác thực, 403 là không được phép.
- Mật khẩu: băm một chiều chậm kèm salt (Argon2, bcrypt), kết hợp MFA, giới hạn số lần thử.
- Kiểm tra quyền ở server cho mọi truy cập dữ liệu, đặc biệt quyền sở hữu, để tránh IDOR; mặc định từ chối.