99 Ngày Spring — Ngày 43: User từ database
Hai ngày qua ta đăng nhập bằng người dùng cứng trong bộ nhớ. Hôm nay nối vào database thật — và điều dễ chịu là Spring Security chỉ đòi bạn cung cấp đúng một method: tìm người dùng theo tên đăng nhập. Phần còn lại, từ DaoAuthenticationProvider tới việc so mật khẩu bằng PasswordEncoder của Ngày 42, Boot tự lắp. Đổi lại, có bốn cái bẫy nên biết trước — trong đó cái đầu tiên là một lỗ hổng bảo mật thật sự.

UserDetailsService — chỉ một method
Đây là chỗ Spring Security thiết kế rất gọn: nó không quan tâm dữ liệu người dùng nằm ở đâu — JPA, MongoDB, LDAP hay một API bên ngoài. Nó chỉ hỏi bạn một câu, và bạn trả lời bằng một đối tượng UserDetails.
// Hai ngày qua ta dùng người dùng cứng trong bộ nhớ. Giờ thay bằng người dùng THẬT.
// Spring Security chỉ cần bạn cung cấp MỘT method: tìm người dùng theo tên đăng nhập.
@Service
class UserDetailsServiceImpl implements UserDetailsService {
private final NguoiDungRepository repo;
@Override
public UserDetails loadUserByUsername(String email) {
NguoiDung nd = repo.findByEmail(email)
.orElseThrow(() -> new UsernameNotFoundException("Không thấy: " + email));
return User.withUsername(nd.getEmail())
.password(nd.getMatKhauDaBam()) // ❗ mật khẩu ĐÃ BĂM, không phải thô
.authorities(nd.getQuyen().stream()
.map(q -> new SimpleGrantedAuthority("ROLE_" + q.name()))
.toList())
.disabled(!nd.isDangHoatDong())
.build();
}
}
// ❗ CHỈ khai bean này là XONG. Boot tự dựng DaoAuthenticationProvider dùng nó,
// cộng với PasswordEncoder bạn đã khai ở Ngày 42. Không cần cấu hình gì thêm.
// Luồng đăng nhập diễn ra như sau:
// 1. Filter nhận username + password thô từ form
// 2. DaoAuthenticationProvider gọi loadUserByUsername(username)
// 3. Gọi passwordEncoder.matches(mật khẩu thô, mật khẩu đã băm)
// 4. Khớp → dựng Authentication và cất vào SecurityContext (Ngày 41)
// 5. Không khớp → BadCredentialsException → 401Khai bean này là xong: Boot tự dựng
DaoAuthenticationProviderquanh nó, kết hợp vớiPasswordEncodercủa hôm qua.Trả về mật khẩu đã băm, không bao giờ là mật khẩu thô — provider sẽ tự gọi
matches.Nắm được năm bước của luồng đăng nhập thì gỡ lỗi đăng nhập nhanh hơn hẳn, vì bạn biết chính xác nên đặt log ở đâu.
Entity người dùng và chuyện tiền tố ROLE_
Vài quyết định nhỏ ở entity sẽ theo bạn rất lâu, và quan trọng nhất là chuyện tiền tố ROLE_ — chính cái bẫy đã nói ở Ngày 41, giờ quay lại dưới dạng một lựa chọn thiết kế.
// Entity người dùng — vài quyết định nhỏ nhưng ảnh hưởng lâu dài:
@Entity
class NguoiDung {
@Id @GeneratedValue Long id;
@Column(unique = true, nullable = false)
String email; // định danh đăng nhập
@Column(nullable = false)
String matKhauDaBam; // ❗ ĐẶT TÊN cho rõ đây là bản BĂM
@Enumerated(EnumType.STRING) // Ngày 39: LUÔN dùng STRING
@ElementCollection(fetch = FetchType.EAGER) // quyền là ngoại lệ hiếm được EAGER
Set<Quyen> quyen;
boolean dangHoatDong = true;
}
enum Quyen { USER, ADMIN } // KHÔNG có tiền tố ROLE_ ở đây
// ❗ Quyết định quan trọng: LƯU "USER"/"ADMIN" trong DB, và THÊM tiền tố "ROLE_"
// khi dựng UserDetails. Vì sao? Vì hasRole("ADMIN") tự thêm tiền tố (Ngày 41),
// nên nếu DB đã có ROLE_ thì bạn phải nhớ điều đó ở MỌI chỗ dùng. Thêm một lần
// tại một nơi duy nhất thì dễ kiểm soát hơn nhiều.
// Đăng ký người dùng — chỗ duy nhất được phép chạm vào mật khẩu thô:
@Transactional
public void dangKy(String email, String matKhauTho) {
if (repo.existsByEmail(email))
throw new EmailDaTonTaiException(email); // Ngày 43 phần Java!
var nd = new NguoiDung(email, encoder.encode(matKhauTho));
repo.save(nd);
}
// ❗ Mật khẩu thô: KHÔNG log, KHÔNG lưu, KHÔNG đưa vào message exception.Lưu "ADMIN" trong database, thêm "ROLE_" lúc dựng UserDetails — thêm một lần ở một nơi duy nhất, thay vì phải nhớ ở mọi chỗ dùng.
Đặt tên field là
matKhauDaBamchứ không phảimatKhau: cái tên tự nó ngăn người sau lưu nhầm mật khẩu thô.Đăng ký là nơi duy nhất chạm vào mật khẩu thô — và nó không được log, không được lưu, không được đưa vào message exception.
Bốn cái bẫy
Cái bẫy đầu tiên đáng chú ý nhất, vì nó trông giống một cải tiến trải nghiệm người dùng: phân biệt "email không tồn tại" với "sai mật khẩu". Thực chất đó là tặng cho kẻ tấn công một công cụ dò danh sách email.
// BỐN CÁI BẪY khi nối Security với database thật:
// 1. THÔNG BÁO LỖI TIẾT LỘ QUÁ NHIỀU:
"Email không tồn tại" // ❌ cho kẻ tấn công biết email nào CÓ trong hệ thống
"Sai mật khẩu" // ❌ tương tự
"Email hoặc mật khẩu không đúng" // ✅ cùng một câu cho cả hai trường hợp
// Spring mặc định đã gộp cả hai thành BadCredentialsException — đừng "cải tiến" nó.
// 2. N+1 KHI TẢI QUYỀN (Ngày 40 quay lại!):
// quyen là LAZY → loadUserByUsername trả về rồi mới truy cập → LazyInitializationException
// Đây là một trong số rất ít chỗ EAGER là lựa chọn đúng, vì quyền luôn cần
// ngay cùng lúc với người dùng, và số lượng thì nhỏ.
// 3. TÀI KHOẢN BỊ KHÓA KHÔNG ĐƯỢC KIỂM TRA:
// UserDetails có sẵn bốn cờ, và Spring TỰ kiểm tra chúng — nhớ ánh xạ cho đúng:
.disabled(!nd.isDangHoatDong()) // tài khoản bị vô hiệu hóa
.accountLocked(nd.isBiKhoa()) // khóa do đăng nhập sai nhiều lần
.accountExpired(false).credentialsExpired(false);
// 4. ĐỔI MẬT KHẨU MÀ KHÔNG HỦY PHIÊN CŨ:
// Đổi mật khẩu xong, các phiên đăng nhập cũ VẪN CÒN HIỆU LỰC.
// Nếu tài khoản bị chiếm thì đổi mật khẩu không đuổi được kẻ đang ở trong.
// Với session: dùng SessionRegistry để hủy hết phiên của người dùng đó.
// Với JWT (Ngày 44–45): đây là điểm yếu cố hữu — token đã phát vẫn sống tới khi hết hạn.Một thông báo cho cả hai trường hợp — Spring mặc định đã làm đúng, đừng "cải tiến" nó.
Quyền là một trong rất ít chỗ EAGER là đúng, vì luôn cần cùng lúc với người dùng và số lượng thì nhỏ.
Bốn cờ của
UserDetailsđược Spring tự kiểm tra — ánh xạ đúng thì chức năng khóa tài khoản có ngay, khỏi viết thêm gì.Đổi mật khẩu không tự hủy phiên cũ: với session thì dùng
SessionRegistry, còn với JWT thì đây là điểm yếu cố hữu sẽ bàn ở Ngày 45.
Bài tập nhỏ
Cài
UserDetailsServiceđọc từ bảng người dùng thật, rồi đăng nhập bằng tài khoản trong database.Lưu quyền kèm tiền tố
ROLE_trong DB rồi dùnghasRole("ROLE_ADMIN")— gặp 403, và sửa lại theo cách của bài.Để
quyenlàLAZYvà quan sátLazyInitializationExceptionxuất hiện lúc đăng nhập.Đặt
disabled(true)cho một tài khoản rồi thử đăng nhập — xác nhận Spring tự chặn mà bạn không viết dòng nào.Đăng nhập ở hai trình duyệt, đổi mật khẩu ở một bên, rồi kiểm tra bên kia — quan sát phiên cũ vẫn sống.
Kết lại
Bốn ý gói lại hôm nay: UserDetailsService chỉ đòi một method, và khai nó là Boot tự lắp DaoAuthenticationProvider cùng PasswordEncoder; lưu quyền không kèm tiền tố và thêm ROLE_ một lần duy nhất lúc dựng UserDetails; một thông báo lỗi cho cả email sai lẫn mật khẩu sai, vì phân biệt chúng là tặng công cụ dò email cho kẻ tấn công; và nhớ rằng đổi mật khẩu không tự hủy phiên cũ. Ngày 44 ta bước sang thế giới stateless: JWT — cấu trúc token, ký và xác minh, và dựng endpoint đăng nhập phát token. Hẹn gặp lại!
Site Admin
Engineer and writer. Building things with TypeScript and distributed systems.
Bình luận (0)
Bạn cần đăng nhập bằng Google để bình luận.
Hãy là người bình luận đầu tiên.


