Ứ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:pip3 install flask-mail
pip3 install pyjwt
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
... from flask_mail import Mail app = Flask(__name__) ... mail = Mail(app)
python -m smtpd -n -c DebuggingServer localhost:8025
$ export MAIL_SERVER=localhost $ export MAIL_PORT=8025
$ export MAIL_SERVER=smtp.googlemail.com $ export MAIL_PORT=587 $ export MAIL_USE_TLS=1 $ export MAIL_USERNAME=<your-gmail-username> $ export MAIL_PASSWORD=<your-gmail-password>
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:>>> from flask_mail import Message >>> from app import mail >>> msg = Message('test subject', sender=app.config['ADMINS'][0], ... recipients=['your-email@example.com']) >>> msg.body = 'text body' >>> msg.html = '<h1>HTML body</h1>' >>> mail.send(msg)
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
from flask_mail import Message from app import mail def send_email(subject, sender, recipients, text_body, html_body): msg = Message(subject, sender=sender, recipients=recipients) msg.body = text_body msg.html = html_body mail.send(msg)
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
<p> Forgot Your Password? <a href="{{ url_for('reset_password_request') }}">Click to Reset</a> </p>
- app/forms.py: Form phục hồi mật mã
class ResetPasswordRequestForm(FlaskForm): email = StringField('Email', validators=[DataRequired(), Email()]) submit = SubmitField('Request Password Reset')
- app/templates/reset_password_request.html: Template yêu cầu phục hồi mật mã
{% extends "base.html" %} {% block content %} <h1>Reset Password</h1> <form action="" method="post"> {{ form.hidden_tag() }} <p> {{ form.email.label }}<br> {{ form.email(size=64) }}<br> {% for error in form.email.errors %} <span style="color: red;">[{{ error }}]</span> {% endfor %} </p> <p>{{ form.submit() }}</p> </form> {% endblock %}
- app/routes.py: Hàm hiển thị cho chức năng phục hồi mật mã
from app.forms import ResetPasswordRequestForm from app.email import send_password_reset_email @app.route('/reset_password_request', methods=['GET', 'POST']) def reset_password_request(): if current_user.is_authenticated: return redirect(url_for('index')) form = ResetPasswordRequestForm() if form.validate_on_submit(): user = User.query.filter_by(email=form.email.data).first() if user: send_password_reset_email(user) flash('Check your email for the instructions to reset your password') return redirect(url_for('login')) return render_template('reset_password_request.html', title='Reset Password', form=form)
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:
>>> import jwt >>> token = jwt.encode({'a': 'b'}, 'my-secret', algorithm='HS256') >>> token b'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJhIjoiYiJ9.dvOo58OBDHiuSHD4uW88nfJikhYAXc_sfUHq1mDi4G0' >>> jwt.decode(token, 'my-secret', algorithms=['HS256']) {'a': 'b'}
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
from time import time import jwt from app import app class User(UserMixin, db.Model): ... def get_reset_password_token(self, expires_in=600): return jwt.encode( {'reset_password': self.id, 'exp': time() + expires_in}, app.config['SECRET_KEY'], algorithm='HS256').decode('utf-8') @staticmethod def verify_reset_password_token(token): try: id = jwt.decode(token, app.config['SECRET_KEY'], algorithms=['HS256'])['reset_password'] except: return return User.query.get(id)
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ã
from flask import render_template from app import app ... def send_password_reset_email(user): token = user.get_reset_password_token() send_email('[Myblog] Reset Your Password', sender=app.config['ADMINS'][0], recipients=[user.email], text_body=render_template('email/reset_password.txt', user=user, token=token), html_body=render_template('email/reset_password.html', user=user, token=token))
- app/templates/email/reset_password.txt: Nội dung email phục hồi mật mã dạng văn bản
Dear {{ user.username }}, To reset your password click on the following link: {{ url_for('reset_password', token=token, _external=True) }} If you have not requested a password reset simply ignore this message. Sincerely,
- app/templates/email/reset_password.html: Nội dung email phục hồi mật mã dạng HTML
<p>Dear {{ user.username }},</p> <p> To reset your password <a href="{{ url_for('reset_password', token=token, _external=True) }}"> click here </a>. </p> <p>Alternatively, you can paste the following link in your browser's address bar:</p> <p>{{ url_for('reset_password', token=token, _external=True) }}</p> <p>If you have not requested a password reset simply ignore this message.</p> <p>Sincerely,</p>
- Đị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ã
from app.forms import ResetPasswordForm @app.route('/reset_password/<token>', methods=['GET', 'POST']) def reset_password(token): if current_user.is_authenticated: return redirect(url_for('index')) user = User.verify_reset_password_token(token) if not user: return redirect(url_for('index')) form = ResetPasswordForm() if form.validate_on_submit(): user.set_password(form.password.data) db.session.commit() flash('Your password has been reset.') return redirect(url_for('login')) return render_template('reset_password.html', form=form)
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ã
class ResetPasswordForm(FlaskForm): password = PasswordField('Password', validators=[DataRequired()]) password2 = PasswordField( 'Repeat Password', validators=[DataRequired(), EqualTo('password')]) submit = SubmitField('Request Password Reset')
- app/templates/reset_password.html: template phục hồi mật mã
{% extends "base.html" %} {% block content %} <h1>Reset Your Password</h1> <form action="" method="post"> {{ form.hidden_tag() }} <p> {{ form.password.label }}<br> {{ form.password(size=32) }}<br> {% for error in form.password.errors %} <span style="color: red;">[{{ error }}]</span> {% endfor %} </p> <p> {{ form.password2.label }}<br> {{ form.password2(size=32) }}<br> {% for error in form.password2.errors %} <span style="color: red;">[{{ error }}]</span> {% endfor %} </p> <p>{{ form.submit() }}</p> </form> {% endblock %}
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.
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ộ
from threading import Thread ... def send_async_email(app, msg): with app.app_context(): mail.send(msg) def send_email(subject, sender, recipients, text_body, html_body): msg = Message(subject, sender=sender, recipients=recipients) msg.body = text_body msg.html = html_body Thread(target=send_async_email, args=(app, msg)).start()
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.