- Published on
Thiết kế hệ thống OTP Authentication.
- Authors
- Name
- Dinh Nguyen Truong
Khi sử dụng phần mềm, chắc hẳn mọi người đã từng gặp tình huống nhập email, password xong lại phải chờ email gửi về một mã số để xác nhận đăng nhập. Đó chính là hệ thống OTP (One-Time Password) Authentication - một lớp bảo mật quan trọng mà hầu hết các ứng dụng hiện đại đều áp dụng.
Trong bài viết này mình sẽ chia sẻ suy nghĩ cá nhân mình về việc làm sao thiết kế được một hệ thống OTP authentication đáp ứng yêu cầu về mặt chức năng, mở rộng sẽ như thế nào.
Yêu cầu bài toán
Sau khi người dùng đăng nhập bằng email, password, nếu khớp, OTP sẽ được gửi về mail người dùng.
OTP sẽ giúp người dùng đăng nhập ở bước tiếp theo.
Sau khi đăng nhập thành công thì OTP sẽ bị vô hiệu hóa.
Sau một thời gian không sử dụng thì OTP sẽ bị hết hạn.
Trong bài toán này thì email được coi như thông tin nhận dạng duy nhất của người dùng.
1. Thiết kế model
Để hỗ trợ toàn bộ tính năng trên, model của chúng ta phải chứa thông tin sau:
Mật khẩu dùng một lần (OTP)
Email người dùng
Thời gian hết hạn của OTP
Đối với OTP lưu vào database, chúng ta có thể xem xét lưu dưới dạng hash, đề phòng việc dữ liệu bị đánh cắp. Việc khớp giá trị hash với OTP cũng được thực thi khá đơn giản.
2. Thiết kế luồng tổng quan
Sau khi người dùng yêu cầu OTP, API sẽ kiểm tra thông tin email của người dùng trước, sau đó tạo bản ghi lưu trữ email, OTP và thời gian hết hạn, rồi gửi OTP về email của người dùng.
Sau đó người dùng sử dụng OTP này để đăng nhập vào hệ thống. Sau khi đăng nhập thành công hệ thống sẽ xóa nó luôn.
Cách thiết kế này đã bao quát đầy đủ yêu cầu chức năng của bài toán.
Một số vấn đề mà mình thấy có thể xem xét tiếp ở thiết kế này:
Có nhất thiết phải lưu trữ thông tin mật khẩu vào cơ sở dữ liệu chính?
Nếu người dùng đăng nhập lại liên tiếp nhiều lần để lấy OTP thì sao?
Nếu người dùng bị lộ mật khẩu chính thì sao ? Làm sao để tránh bị tấn công brute force?
Làm sao để xử lý 1.000 user đăng nhập cùng lúc và cần gửi OTP
Chọn cơ sở dữ liệu chính hay Cache ?
Tính chất của OTP sẽ là:
Có thời gian sống ngắn, tự động hết hạn
Chỉ được sử dụng một lần duy nhất
Không cần lưu trữ lâu dài
Chính vì những tính chất này, mà lưu trữ mật khẩu dùng một lần vào Cache (Redis) sẽ phù hợp hơn.
Redis cung cấp khả năng tự động hết hạn.
Tốc độ tốt.
Cách lưu trữ cũng rất đơn giản, ta hoàn toàn có thể lưu như sau.
// expired in 60 sec
const otp = generateOTP()
const result = await redis.setex('otp:user-mail@gmail.com', 60, otp);
Người dùng đăng nhập lại liên tiếp nhiều lần để lấy OTP
Trong trường hợp này thì tùy thuộc vào yêu cầu nghiệp vụ mà ta sẽ có cách xử lý khác nhau
- Không cho phép sử dụng OTP cũ
Với phương án sử dụng redis bên trên, thì các OTP cũ sẽ bị ghi đè, chỉ một OTP mới nhất được sử dụng cho mỗi user. Mình nghĩ đây phương án đơn giản nhất.
const result = await redis.setex('otp:user-mail@gmail.com', 60, otp));
- Cho phép sử dụng OTP cũ.
Với yêu cầu này thì ta cần cách chọn key cho phù hợp, ví dụ như
const result = await redis.setex('otp:{uuid1}:user@gmail.com', 60, otp));
const result = await redis.setex('otp:{uuid2}:user@gmail.com', 60, otp));
Theo suy nghĩ của mình thì yêu cầu này sẽ khiến OTP thiếu an toàn hơn ( nhiều OTP tồn tại cùng một lúc). Và việc thực thi cũng phức tạp hơn.
Nếu người dùng bị lộ mật khẩu chính thì sao ?
Khi này thì sau khi đăng nhập thành công, hacker hoàn toàn có thể brute force api xác minh OTP cho đến khi tìm ra mã chính xác.
Chúng ta có 2 cách để xử lý vấn đề này:
Giảm thời gian TTL của OTP xuống => hacker có ít thời gian tìm ra OTP chính xác
Hạn chế gửi api xác minh OTP của người dùng xuống (Rate limit) => hacker ít có cơ hội tiếp cận với dữ liệu của chúng ta hơn.
Ví dụ: chúng ta có thể đặt TTL là 1 phút, Rate limit theo email người dùng khoảng 5 lần xác minh 1 phút. Như vậy cơ hội tấn công của hacker sẽ rất thấp mà trải nghiệm người dùng không bị ảnh hưởng.
Làm sao để xử lý 1.000 user đăng nhập cùng lúc và cần gửi OTP
1000 người dùng đăng nhập cùng lúc đồng nghĩa với việc phải query cơ sở dữ liệu và kiểm tra thông tin tài khoản 1000 lần, đồng thời gửi 1000 OTP.
Đối với API, thêm load balancer kết hợp với đặt thêm nhiều node sẽ giúp cho việc scale và chịu lỗi tốt hơn.
Đối với cơ sở dữ liệu, nó hoàn toàn có thể chịu được số lượng query này cùng lúc với cấu hình phù hợp. Ngoài ra chúng ta có thể tính đến mở rộng thêm Read Replica để tăng hiệu năng query.
Việc gửi OTP thông qua mail service thì scale sẽ khó khăn hơn do giới hạn của mail service chúng ta lựa chọn (Rate limit). Cách giải quyết trong trường hợp này:
Ta có thể đặt thêm queue và worker cho việc gửi mail riêng.
Xử lý retry cho các email lỗi sẽ dễ dàng hơn
Giúp tách biệt 2 phần logic, scale từng phần theo một cách riêng sẽ dễ dàng.
Tiếp theo là phần xác minh OTP, 1.000 đăng nhập cùng lúc không có nghĩa là các user sẽ cùng xác minh OTP. Hiệu năng của Redis hoàn toàn có thể đạt trên 1k read-write/sec (dựa trên official bench mark) nên việc scale api này cũng đơn giản hơn.
Cuối cùng chúng ta có thiết kế tổng kết cho bài toán.
Summary:
Mình tin rằng trong hệ thống thực tế còn rất nhiều thứ cần phải xem xét, bài viết này chỉ là cách tiếp cận cá nhân của mình dựa trên kinh nghiệm thực tế.
Key Takeaways cho mọi người:
OTP có thể phù hợp với cache hơn database
Rate limiting giúp bảo vệ API
Queue giúp scale email sending, và logic mang tính async khác.