Hướng dẫn lập trình Flask – Phần 10: Hỗ trợ email

flask_tutorial_1

Trong phần này, chúng ta sẽ xây dựng chức năng gởi email cho các user cũng như phục hồi mật mã qua email.

Để giúp cho bạn dễ theo dõi, sau đây là danh sách các bài viết trong loạt bài hướng dẫn này:

Bạn có thể truy cập mã nguồn cho phần này tại GitHub.

Ứng dụng của chúng ta đã hoạt động tốt với cơ sở dữ liệu. Vì vậy, trong phần này, chúng ta sẽ tạm thời chuyển sang một chủ đề khác: tìm hiểu một thành phần quan trọng mà hầu hết các ứng dụng Web đều phải có, đó là chức năng gởi email.

Tại sao ứng dụng lại cần phải gởi email cho các user? Có nhiều lý do, nhưng phổ biến nhất là để hỗ trợ cho các vấn đề có liên quan đến quá trình xác thực người sử dụng (authentication). Trong phần này, chúng ta sẽ xây dựng chức năng phục hồi mật mã trong trường hợp user quên mật mã của họ. Khi một user có yêu cầu phục hồi mật mã, ứng dụng của chúng ta sẽ gởi một email có chứa một URL đặc biệt và user chỉ cần bấm vào link đó để truy cập vào form cho phép họ phục hồi mật mã

Giới thiệu Flask-Mail

Flask có một thư viện mở rộng rất phổ biến là Flask-Mail để hỗ trợ cho việc gởi email một cách dễ dàng. Như thường lệ, chúng ta sẽ cài đặt thư viện này bằng pip:

Các liên kết để phục hồi password có chứa một token bảo mật (một chữ ký hay chuỗi được mã hóa). Để tạo ra các token này, chúng ta sẽ dùng một gói Python khác cũng rất phổ biến là JSON Web Token:

Cấu hình cho thư viện Flask-Mail được thiết lập trong đối tượng app.config. Bạn còn nhớ chúng ta đã thiết lập một vài tham số trong đó để nhận các thông báo bằng email khi ứng dụng có sự cố trong Phần 7 không? Thật ra các tham số mà chúng ta đã thiết lập đều dựa trên các yêu cầu của Flask-Mail. Vì vậy, chúng ta không cần thêm gì nữa trong cấu hình vì các thông số đã được thiết lập sẵn.

Tương tự như hầu hết các thư viện mở rộng của Flask, chúng ta cần khởi tạo một thực thể cho Flask-Mail sau khi thực thể ứng dụng được khởi tạo. Trong trường hợp này, đó là một đối tượng của lớp Mail:

app/__init__.py: Thực thể Flask-Mail

Nếu bạn muốn kiểm tra khả năng gởi email, bạn có thể dùng một trong hai cách mà chúng ta đã tìm hiểu qua trong Phần 7. Bạn có thể chọn sử dụng một chương trình mô phỏng máy chủ email có sẵn trong Python và được kích hoạt như sau:

Chương trình này cần có hai biến môi trường như sau:

Hoặc nếu bạn muốn gởi email thật, bạn cần có một máy chủ email thật sự. Nếu bạn có một máy chủ như vậy, bạn chỉ cần thiết lập các biến môi trường MAIL_SERVER, MAIL_PORT, MAIL_USE_TLS, MAIL_USERNAME MAIL_PASSWORD. Nếu không có, bạn có thể dùng tài khoản Gmail để gởi email với các thiết lập như sau:

Nếu đang sử dụng Windows, bạn hãy thay thế lệnh export bằng lệnh set trong các câu lệnh ở trên.

Lưu ý rằng chức năng bảo mật trong tài khoản Gmail có thể không cho phép ứng dụng gởi email trừ khi bạn chỉ định rõ quyển truy nhập cho các ứng dụng bảo mật thấp hơn (less secure app) trong tài khoản Gmail của bạn. Bạn có thể tìm hiểu thêm về thiết lập này ở đây, và nếu bạn lo ngại về an toàn của tài khoản của bạn, bạn có thể tạo tài khoản thứ hai chỉ để kiểm tra chứn năng email, hoặc bạn có thể tạm thời cho phép quyển truy nhập này trong thời gian kiểm tra và tắt nó đi sau đó.

Cách sử dụng Flask-Mail

Để tìm hiểu cách hoạt động của Flask-Mail, chúng ta sẽ thử gởi một email từ trình thông dịch của Python. Bạn hãy gọi Python với lệnh flask shell và nhập vào các lệnh sau:

Các lệnh này sẽ gởi một email đến danh sách các người nhận được chỉ định trong tham số recipient.  Chúng ta đặt người gởi là tên đầu tiên trong danh sách các quản trị viên của ứng dụng (Chúng ta đã thiết lập tên của các quản trị viên trong tham số cấu hình ADMINS trong Phần 7). Email sẽ được hiển thị với một trong hai định dạng: văn bản và HTML, tùy theo phần mềm email bạn đang sử dụng.

Thật dơn giản phải không? Bây giờ, chúng ta hãy tích hợp chức năng email vào ứng dụng.

Nền tảng email đơn giản cho ứng dụng

Chúng ta sẽ khởi tạo một hàm trợ giúp (helper function) để gởi email. Hàm này đơn giản là một phiên bản tổng quát hóa của các lệnh Python mà chúng ta vừa thực hiện ở phần trên. Và chúng ta sẽ đặt hàm này vào một module gọi là app/email.py:

app/email.py: Hàm trợ giúp để gởi email

Flask-Mail có hỗ trợ một vài chức năng mà chúng ta không sử dụng ở đây như là các danh sách Cc và Bcc. Nếu muốn tìm hiểu về các chức năng này, bạn có thể đọc thêm các tài liệu hỗ trợ của Flask-Mail tại đây: Flask-Mail Documentation.

Yêu cầu phục hồi mật mã (Password reset)

Như đã nói ở trên, chúng ta muốn cung cấp chức năng để user có thể yêu cầu phục hồi mật mã của họ nếu cần. Để làm điều này, đầu tiên chúng ta sẽ thêm một liên kết vào trang đăng nhập:

app/templates/login.html: Liên kết phục hồi mật mã trong trang đăng nhập

Khi user bấm vào liên kết này, ứng dụng sẽ hiển thị một form để user nhập vào địa chỉ email để bắt đầu tiến trình phục hồi mật mã. Mã của form như sau:

app/forms.py: Form phục hồi mật mã

Và template HTML tương ứng của form:

app/templates/reset_password_request.html: Template yêu cầu phục hồi mật mã

Chúng ta cũng cần tạo hàm hiển thị cho form:

app/routes.py: Hàm hiển thị cho chức năng phục hồi mật mã

Hàm này cũng tương tự như các hàm hiển thị khác có xử lý form. Chúng ta mở đầu bằng cách kiểm tra xem user có đăng nhập hay không. Nếu user đã đăng nhập, hiển nhiên chức năng này là không cần thiết và chúng ta chỉ cần chuyển hướng trở lại trang chủ.

Nếu form được gởi với các dữ liệu hợp lệ, chúng ta sẽ tìm thông tin về user từ địa chỉ email được cung cấp trong form. Nếu tìm ra user, chúng ta sẽ gởi email phục hồi mật mã đến địa chỉ email tương ứng bằng hàm hỗ trợ send_password_reset_email().

Sauk khi gởi email, chúng ta sẽ hiển thị một thông báo để hướng dẫn user kiểm tra email của họ và làm theo các hướng dẫn trong email đó và chuyển hướng về trang đăng nhập. Ứng dụng sẽ hiển thị thông điệp hướng dẫn này ngay cả khi địa chỉ email được cung cấp không có thật để phòng ngừa trường hợp các thông tin trong form này được sử dụng để tìm ra một người nào đó có phải là user của ứng dụng này hay không.

Token để phục hồi mật mã

Trước khi xây dựng hàm send_password_reset_email(), chúng ta cần tìm ra cách để tạo ra liên kết cho yêu cầu phục hồi mật mã. Liên kết này sẽ được gởi đến user bằng email. Khi user bấm vào liên kết này, họ sẽ được truy cập một trang Web trong ứng dụng cho phép họ nhập mật mã mới. Khó khăn ở đây là làm sao để bảo đảm rằng chỉ có các liên kết hợp lệ mới có thể được dùng để phục hồi mật mã.

Liên kết sẽ được tạo ra kèm với một token, token này sẽ được kiểm định trước khi mật mã được thay đổi. TÍnh hợp lệ của token là bằng chứng user đã nhận và sử dụng liên kết phục hồi mật mã qua tài khoản email đã được đăng ký. Một loại token rất phổ biến cho các công việc kiểu này là JSON Web Token hay là JWT. Vậy JWT làm việc thế nào? Chúng ta sẽ cùng tìm hiểu qua ví dụ sau:

Trong ví dụ trên, từ điển {'a': 'b'} là dữ liệu mà chúng ta sẽ thử đưa vào token. Để cho token có khả năng bảo mật, chúng ta cần dùng một khóa bí mật (secret key) để tạo một chữ ký mã hóa. Trong ví dụ này chúng ta sẽ dùng chuỗi my-secret để làm điều này, nhưng trong ứng dụng, chúng ta sẽ dùng giá trị của tham số SECRET_KEY từ dối tượng cấu hình. Tham số algorithm chỉ định cách thức mã hóa. Trong trường hợp này, chúng ta sử dụng giải thuật HS256 là giải thuật rất phổ biến để mã hóa.

Như bạn thấ, kết quả của quá trình này là một chuỗi các ký tự rất dài. Tuy nhiên chuỗi này không phải là một chuỗi bảo mật. Nội dung của token, bao gồm cả dữ liệu bên trong có thể được giải mã dễ dàng bởi bất kỳ ai (Nếu không tin, bạn có thể nhập giá trị của token vào chương trình JWT debugger để xem nội dung của nó). Điều làm cho token được bảo mật ở đây là dữ liệu bên trong được ký (signed) bằng một chữ ký số. Nếu có ai đó tìm cách tạo ra hay thay đổi giá trị của dữ liệu bên trong token, chữ ký của token sẽ không hợp lệ, và để tạo một chữ ký mới thì lại cần có khóa bí mật. Khi một token được xác nhận, nội dung của dữ liệu sẽ được giải mã và trả về cho chương trình gọi nó. Nếu chữ ký là hợp lệ, dữ liệu bên trong của token sẽ được xem là an toàn và có thể tin cậy được.

Dữ liệu mà chúng ta sẽ chèn vào trong token phục hồi mật mã sẽ có định dạng: {'reset_password': user_id, 'exp': token_expiration}. Trường exp là trường tiêu chuẩn của JWT và chỉ định thời gian hết hạn của token trong trường hợp nó hiện diện trong token. Ngay cả khi token có chữ ký hợp lệ nhưng đã qua thời gian hết hạn trong trường này, nó cũng sẽ bị xem là bất hợp lệ. Trong quá trình phục hồi mật mã, chúng ta sẽ quy định các token này sẽ hết hạn trong 10 phút.

Khi user bấm vào liên kết trong email họ nhận được, token sẽ được gởi về ứng dụng kèm theo URL. Vì vậy việc đầu tiên hàm hiển thị cho URL này cần làm là xác nhận nó. Nếu chữ ký hợp lệ, user có thể được xác định thông qua ID trong dữ liệu chứa trong token. Và sau khi tìm được user, ứng dụng có thể hỏi mật mã mới và lưu vào tài khoản của user.

Bởi vì các token này thuộc về các user, chúng ta sẽ viết các phương thức để tạo ra và xác nhận token trong lớp User:

app/models.py: Các phương thức liên quan đến token

Hàm get_reset_password_token() sẽ tạo ra một JWT token dưới dạng một chuỗi các ký tự. Lưu ý là chúng ta phải dùng hàm decode('utf-8') bởi vì hàm jwt.encode() trả về một token dưới dạng một chuỗi các byte, nhưng sử dụng token theo dạng chuỗi ký tự tiện lợi hơn nhiều trong ứng dụng,

verify_reset_password_token() là một phương thức tĩnh (static), có nghĩa là chúng ta có thể gọi nó trực tiếp từ lớp chứ không cần khởi tạo đối tượng mới cho nó. Một phương thức tĩnh cũng tương tự như các phương thức bình thường, điểm khác nhau duy nhât là các phương thức tĩnh không nhận tham số về lớp làm tham số đầu tiên. Phương thức này sẽ nhận vào một token và thử giả mã nó bằng cách gọi hàm jwt.decode() từ thư viện PyJWT . Nếu quá trình xác thực token thất bại hoặc token quá hạn, chương trình sẽ sinh ra ngoại lệ. Trong trường hợp đó, chúng ta sẽ phải xử lý nó để ngăn lỗi xảy ra và trả về giá trị None cho chương trình gọi nó. Nếu token hợp lệ, giá trị của khóa reset_password trong dữ liệu của token sẽ là ID của user. Chúng ta có thể sử dụng ID này để lấy thông tin của user từ cơ sở dữ liệu.

Gởi email phục hồi mật mã

Sau khi đã có token, chúng ta có thể tạo ra email để gởi cho user. Dựa trên hàm send_email() mà chúng ta đã viết ở trên, chúng ta có thể xây dựng hàm send_password_reset_email() như sau:

app/email.py: Gởi email phục hồi mật mã

Điều thú vị ở đây là phần văn bản và mã HTML trong nội dung email được tạo ra từ các template và sử dụng hàm render_template(). Các template sẽ nhận các tham số là thông tin về user và token và tạo ra một email được cá nhân hóa cho phù hợp với từng user. Sau đây là template dạng văn bản cho email phục hồi mật mã:

app/templates/email/reset_password.txt: Nội dung email phục hồi mật mã dạng văn bản

Và phiên bản HTML tương ứng:

app/templates/email/reset_password.html: Nội dung email phục hồi mật mã dạng HTML

Khi gọi hàm url_for() trong hai template trên, chúng ta sử dụng một số tham số như sau:

  • Địa chỉ reset_password, tạm thời chúng ta chưa có hàm hiển thị cho địa chỉ này, nhưng chúng ta sẽ viết nó trong phần tiếp theo.
  • Tham số _external=True: đây là một tham số mới. Theo mặc định, các URL được hàm url_for() tạo ra là các địa chỉ tương đối (ví dụ như hàm url_for('user', username='nguyen') sẽ trả về giá trị /user/nguyen. Đối với các liên kết Web thì địa chỉ tương đối là đủ vì trình duyệt sẽ tự động ghép các địa chỉ này với phần còn lại trong địa chỉ của trang Web hiện hành. Tuy nhiên, khi chúng ta gởi địa chỉ qua email, chúng ta không có các thông tin về trang hiện hành, vì vậy, chúng ta cần dùng địa chỉ đầy đủ. Khi sử dụng _external=True, hàm url_for() sẽ tạo ra địa chỉ đầy đủ (theo ví dụ trên, địa chỉ đầy đủ do url_for() tạo ra sẽ là http://localhost:5000/user/nguyen hay là địa chỉ thích hợp khi triển khai ứng dụng trên máy chủ với tên miền cụ thể).

Phục hồi mật mã

Khi user bấm vào liên kết trong email, họ sẽ truy cập liên kết reset_password mà chúng ta vừa nói ở trên. Sau đây là hàm hiển thị cho liên kết đó:

app/routes.py: Hàm hiển thị cho chức năng phục hồi mật mã

Trong hàm hiển thị này, đầu tiên chúng ta sẽ kiểm tra để bảo đảm rằng user chưa đăng nhập. Tiếp theo, chúng ta sẽ xác nhận user bằng cách gọi hàm kiểm tra token trong lớp User. Hàm này sẽ trả về true nếu token hợp lệ hay None nếu không. Nếu token không hợp lệ, chúng ta sẽ chuyển hướng về trang chủ.

Nếu token hợp lệ, chúng ta sẽ hiển thị form để nhập mật mã mới cho user. Form này cũng được xử lý tương tự như các form trước đây, và nếu dữ liệu được gởi về server là hợp lệ, chúng ta sẽ gọi hàm set_password() từ lớp User để thay đổi mật mã và chuyển hướng về trang đăng nhập để người sử dụng có thể đăng nhập với mật mã mới.

Sau đây là lớp ResetPasswordForm:

app/forms.py: form phục hồi mật mã

Và mã HTML trong template tương ứng:

app/templates/reset_password.html: template phục hồi mật mã

Đến đây chúng ta đã hoàn thành chức năng phục hồi mật mã, bạn có thể thử xem nó hoạt động thế nào.

Email bất đồng bộ (Asynchronous Emails)

Nếu bạn đang sử dụng chương trình mô phỏng máy chủ email có sẵn trong Python thì có thể bạn không nhận ra, nhưng việc gởi email sẽ làm ứng dụng chậm đi một cách đáng kể bởi vì các tương tác xảy ra trong quá trình gởi email. Việc gởi email thường mất vài giây hoặc hơn nếu máy chủ email của người nhận chạy chậm hoặc nếu có nhiều người nhận cùng lúc.

Vì vậy, chúng ta muốn làm cho chức năng gởi email hoạt động bất đồng bộ (asynchronous). Có nghĩa là khi chúng ta gọi hàm send_email() để gởi email, tác vụ gởi email sẽ được thực thi trong chế độ nền (background) và cho phép hàm này trả về ngay lập tức để ứng dụng có thể tiếp tục chạy trong quá trình gởi email.

Gởi email đồng bộ
Gởi email bất đồng bộ

Python có hỗ trợ cho các tác vụ bất đồng bộ qua module threading hoặc multiprocessing. Tuy nhiên, khởi tạo một tác vụ trong chế độ nền bằng thread (luồng) để gởi email thì ít sử dụng tài nguyên hệ thống hơn là tạo ra một process (quá trình) mới. Vì vậy, chúng ta sẽ sử dụng thread:

app/email.py: Gởi email bất đồng bộ

Hàm send_async_email() sẽ thực thi trong một thread ở chế độ nền và được gọi qua lớp Thread() trong dòng cuối của phương thức send_email() ở trên. Với thay đổi này, quá trình gởi email sẽ chạy trong một thread riêng. Sau khi hoàn tất, thread sẽ kết thúc và giải phóng các tài nguyên nó sử dụng mà không ảnh hưởng đến ứng dụng. Nếu bạn có dùng một máy chủ email thật, bạn sẽ thấy ứng dụng phản hồi nhanh hơn khi bạn bấm vào nút “Submit” trong form yêu cầu phục hồi mật mã.

Bạn có thể nghĩ rằng chúng ta chỉ cần dùng tham số msg khi tạo ra thread cho hàm send_async_email(). Tuy nhiên, như bạn đã thấy trong đoạn mã trên, chúng ta không chỉ truyền tham số msg mà còn có cả thực thể ứng dụng. Khi làm việc với thread, chúng ta cần để ý một chi tiết quan trọng về thiết kế của Flask. Flask dùng khái niệm ngữ cảnh (context) để tránh việc phải truyền các tham số giữa các hàm. Chúng ta sẽ không đi sâu vào chi tiết ở đây, nhưng có hai loại ngữ cảnh: ngữ cảnh ứng dụng (application context) và ngữ cảnh yêu cầu (request context). Trong hầu hết các trường hợp, Flask sẽ quản lý các ngữ cảnh này một cách tự động. Nhưng khi ứng dụng tạo ra một thread mới của riêng nó, ngữ cảnh của thread này phải được tạo ra theo cách thủ công.

Nhiều thư viện mở rộng đòi hỏi ngữ cảnh ứng dụng phải được thiết lập trước khi hoạt động vì chúng có thể tìm được thực thể ứng dụng thay vì phải truyền qua tham số. Lý do các thư viện mở rộng này cần biết đến thực thể ứng dụng là chúng có các tham số cấu hình trong đối tượng app.config. Và đây cũng là trường hợp của Flask-Mail. Phương thức mail.send() cần truy cập các tham số cấu hình của máy chủ email, và cách duy nhất là có tham chiếu đến thực thể ứng dụng. Ngữ cảnh ứng dụng được tạo ra khi gọi hàm app.app_context sẽ cho phép chúng ta truy cập đến thực thể ứng dụng qua biến current_app từ Flask.

Đến đây, chúng ta đã hoàn tất chức năng hỗ trợ email cho ứng dụng. Chúng ta sẽ kết thúc phần này và hẹn gặp bạn trong phần tiếp theo.

3 thoughts on “Hướng dẫn lập trình Flask – Phần 10: Hỗ trợ email

  1. Bài viết rất tỉ mỉ, cảm ơn anh.
    Trường hợp user nhập Email sai (email ko tồn tại) dường như chưa được cover. Nên sẽ báo lỗi.
    Em nghĩ nên có thêm trường username trong form reset password. User có thể quên password nhưng ko thể quên cả username (trường hợp login bằng username)

    1. Rất cảm ơn bạn đã theo dõi và đóng góp ý kiến. Loạt bài viết này chỉ nhằm cung cấp cho đọc giả tổng quan về khả năng xây dựng ứng dụng với Flask. Các chức năng còn thiếu trong ứng dụng có thể xem như phần thực tập nhỏ cho các bạn nhé.

  2. Em thấy trong token gửi để reset trong email có expires_in mặc định là 600 nhưng khi nhận được phản hồi từ user em không thấy anh kiểm tra xem đã timeout hay chưa.

Leave a Reply to Hoang Cancel reply

Your email address will not be published. Required fields are marked *