Mã băm cho mật mã của user (Password hashing)
Trong Phần 4, chúng ta đã định nghĩa trường password_hash trong mô hình dữ liệu người dùng. Trường này được dùng để chứa mã hash cho mật mã của user (Để bảo đảm tính nhất quán, từ đây chúng ta sẽ sử dụng thuật ngữ user thay vì người sử dụng). Mã hash này sẽ được dùng để xác thực mật mã do user nhập vào trong quá trình đăng nhập. Quá trình và những nguyên lý tạo ra hash rất phức tạp và chỉ có các chuyên gia về bảo mật mới hiểu rõ, vì thế chúng ta sẽ không đi sâu vào chi tiết ở đây. Tuy nhiên, chúng ta có thể sử dụng một số thư viện Flask để tạo ra hash một cách dễ dàng và nhanh chóng.Trong các thư viện của Flask, có một gói cung cấp các phương thức để làm việc với hash tên là Werkzeug. Nếu để ý quá trình cài đặt Flask, bạn có thể thấy tên của gói này đâu đó trong các thông điệp mà Flask in ra trong suốt quá trình cài đặt bởi vì gói này là một trong những thành phần quan trọng nhất của Flask. Cũng vì lý do đó, Werkzeug đã được cài đặt sẵn vào trong môi trường ảo của bạn ngay từ đầu. Ví dụ sau sẽ minh họa cách tạo hash cho mật mã bằng thư viện Werkzeug:
>>> from werkzeug.security import generate_password_hash >>> hash = generate_password_hash('foobar') >>> hash 'pbkdf2:sha256:150000$3HUtwgW8$39ae9f2f4925661c5cf9cfa04842b04b874730f87739f65316eb6e85f4678c28'
>>> from werkzeug.security import check_password_hash >>> check_password_hash(hash, 'foobar') True >>> check_password_hash(hash, 'barfoo') False
Quá trình tạo hash và xác thực mật mã sẽ được thêm vào hai phương thức mới trong mô hình user như sau:
- app/models.py: Tạo mã hash và xác thực mật mã
from werkzeug.security import generate_password_hash, check_password_hash ... class User(db.Model): ... def set_password(self, password): self.password_hash = generate_password_hash(password) def check_password(self, password): return check_password_hash(self.password_hash, password)
>>> u = User(username='thai', email='thai@example.com') >>> u.set_password('mypassword') >>> u.check_password('anotherpassword') False >>> u.check_password('mypassword') True
Giới thiệu về Flask-Login
Trong phần này, chúng ta cũng sẽ tìm hiểu thêm một thư viện Flask mở rộng rất phổ biến là Flask-Login.Thư viện mở rộng này quản lý tình trạng đăng nhập của user. Nhờ đó, hệ thống có thể ghi nhớ các thông tin về user đã đăng nhập trong một phiên làm viện và cho phép họ truy nhập các trang Web đòi hỏi user phải đăng nhập. Nó cũng cung cấp chức năng “Remember me” để user vẫn giữ được tình trạng đăng nhập ngay cả khi họ đã đóng trình duyệt. Để bắt đầu phần này, chúng ta hãy cài đặt Flask-Login trong môi trường ảo của bạn:
(myenv) $ pip3 install flask-login
- app/__init__.py: Khởi tạo Flask-Login
... from flask_login import LoginManager app = Flask(__name__) ... login = LoginManager(app) ...
Điều chỉnh mô hình dữ liệu user (User) cho Flask-Login
Thư viện Flask-Login sẽ làm việc với mô hình dữ liệu user với một số các thuộc tính và phương thức nhất định. Đây là một thiết kế hay bởi vì nếu các thuộc tính và phương thức này được cung cấp trong mô hình dữ liệu, Flask-Login sẽ làm việc đúng theo yêu cầu bất chấp các điều kiện khác. Ví dụ như nó có thể hoạt động với các mô hình dữ liệu user từ bất kỳ hệ cơ sở dữ liệu nào.Sau đây là bốn yêu cầu của Flask-Login với mô hình dữ liệu user:
- is_authenticated: một thuộc tính sẽ được gán là True nếu user có tên và mật mã hợp lệ, False nếu một trong hai không đúng.
- is_active: một thuộc tính được gán là True nếu tài khoản user trong chế độ hoạt động (active) và False nếu ngược lại.
- is_anonymous: một thuộc tính được gán là False cho những user bình thường, và True cho những user ẩn danh (anonymous)
- get_id(): một phương thức để trả về định danh người dùng (id) dưới dạng chuỗi
- app/models.py: lớp mixin cho user trong Flask-Login
... from flask_login import UserMixin class User(UserMixin, db.Model): ...
Hàm tải thông tin user
Flask-Login theo dõi tình trạng của những user đã đăng nhập bằng cách lưu các ID tương ứng trong các phiên làm việc (user session) – một vùng lưu trữ được xác lập cho mỗi user đang kết nối vào ứng dụng. Mỗi khi một user đã đăng nhập truy cập một trang mới trong ứng dụng, Flask-Login sẽ lấy ID của user đó từ phiên làm việc và tải dữ liệu về user đó vào bộ nhớ.Bởi vì Flask-Login không trực tiếp làm việc với cơ sở dữ liệu, nó cần có sự trợ giúp của các thành phần khác trong ứng dụng để tìm kiếm và tải dữ liệu về user. Vì vậy, thư viện này sẽ cần có một hàm hỗ trợ để tải thông tin user. Hàm này sẽ tìm kiếm và tải các thông tin về user từ cơ sở dữ liệu dựa trên Id của user đó. Chúng ta sẽ thêm mã cho hàm này vào module app/models.py như sau:
- app/models.py: Hàm tải dữ liệu người dùng cho Flask-Login
... from flask_login import current_user, login_user from app.models import User ... @app.route('/login', methods=['GET', 'POST']) def login(): if current_user.is_authenticated: return redirect(url_for('index')) form = LoginForm() if form.validate_on_submit(): user = User.query.filter_by(username=form.username.data).first() if user is None or not user.check_password(form.password.data): flash('Invalid username or password') return redirect(url_for('login')) login_user(user, remember=form.remember_me.data) return redirect(url_for('index')) return render_template('login.html', title='Sign In', form=form)
Trước đây, chúng ta cũng dùng hàm flash() để hiển thị một thông báo là người sử dụng đăng nhập thành công mà không thực sự tiến hành quá trình đăng nhập. Nhưng lần này thì chúng ta có thể thay thế mã giả này bằng mã thật cho quá trình đăng nhập. Để làm điều này, trước hết, chúng ta cần tải dữ liệu về user từ cơ sở dữ liệu. Thông qua form đăng nhập, chúng ta sẽ nhận được username. Giá trị này sẽ được dùng để tìm Id tương ứng của user này trong cơ sở dữ liệu bằng hàm filter_by() của đối tượng query (truy vấn) trong thư viện SQLAlchemy. Hàm filter_by sẽ trả về các đối tượng có giá trị username khớp với username mà chúng ta đã sử dụng. Bởi vì chúng ta biết rằng các username mang tính duy nhất nên chỉ có tối đa một đối tượng user trong cơ sở dữ liệu có username trùng với username mà chúng ta đang tìm. Do đó, chúng ta có thể sử dụng hàm first(), hàm này sẽ trả về một kết quả nếu có một user như vậy hoặc None nếu user không tồn tại trong cơ sở dữ liệu. Trong Phần 4, chúng ta đã thấy kết quả khi gọi hàm all() trong một truy vấn: nó sẽ trả về tất cả kết quả trùng hợp với truy vấn đó trong cơ sở dữ liệu. Hàm first() thường được sử dụng trong trường hợp chúng ta biết trước chỉ có thể có tối đa một kết quả.
Nếu chúng ta tìm được một user từ cơ sở dữ liệu có cùng username như trong form đăng nhập, chúng ta sẽ tiến hành bước tiếp theo là kiểm tra mật mã. Quá trình này được thực hiện bằng cách sử dụng hàm check_password() mà chúng ta đã nói ở phần đầu của bài này. Hàm này sẽ tạo hash từ mật mã do user nhập vào và so sánh với giá trị password_hash từ bảng User. Nếu hai giá trị này giống nhau thì mật mã do user nhập vào là đúng (valid), ngược lại thì mật mã này là sai (invalid). Như vậy, cuối cùng chúng ta có hai khả năng người dùng không được cho phép đăng nhập: username hoặc password nhập vào là không đúng. Trong cả hai trường hợp, chúng ta sẽ dùng flash() để hiển thị một thông báo lỗi và chuyển hướng về trang đăng nhập để user có thể nhập các thông tin lần nữa.
Nếu username và password tương ứng đều đúng, chúng ta sẽ gọi một hàm trong thư viện Flask-Login là login_user() để ghi nhận tình trạng của user này là đã đăng nhập thành công. Điều này cũng đồng nghĩa với việc ứng dụng sẽ tải và lưu giữ các thông tin về user từ database vào bộ nhớ và giữ lại các thông tin này trong suốt phiên làm việc của user (cho đến khi user đăng xuất – logout – ra khỏi ứng dụng). Nhờ đó, khi user truy cập bất kỳ trang nào, ứng dụng cũng có thể dễ dàng tìm được thông tin về họ qua biến current_user.
Sau khi user đăng nhập thành công, chúng ta sẽ chuyển (redirect) user đến trang chủ.
Đăng xuất (Log Out)
Để hoàn tất quá trình xuất nhập, chúng ta cũng phải cung cấp chức năng đăng xuất (log out) ra khỏi ứng dụng. Chúng ta sẽ sử dụng một hàm khác của thư viện Flask-Login gọi là logout_user() cho mục đích này:- app/routes.py: Hàm hiển thị Logout
... from flask_login import logout_user ... @app.route('/logout') def logout(): logout_user() return redirect(url_for('index'))
- app/templates/base.html: Hiển thị liên kết login và logout trên thanh định hướng
<div> Myblog: <a href="{{ url_for('index') }}">Home</a> {% if current_user.is_anonymous %} <a href="{{ url_for('login') }}">Login</a> {% else %} <a href="{{ url_for('logout') }}">Logout</a> {% endif %} </div>
Yêu cầu User đăng nhập
Flask-Login có một chức năng rất hữu ích để yêu cầu người sử dụng đăng nhập nếu họ muốn truy nhập vào một số trang nhất định trong ứng dụng. Nếu một user không đăng nhập tìm cách truy cập các trang này, Flask-Login sẽ tự động chuyển họ đến trang đăng nhập và chuyển trở lại trang được yêu cầu truy cập sau khi user đã đăng nhập thành công.Để sử dụng chức năng này, Flask-Login cần biết hàm hiển thị cho quá trình đăng nhập. Chúng ta sẽ thay đổi file app/__init__.py để làm điều này:
... login = LoginManager(app) login.login_view = 'login'
Chúng ta sẽ dùng decorator @login_required cho những hàm hiển thị nào chỉ cho phép user có đăng nhập truy cập. Khi bạn thêm decorator này vào một hàm hiển thị và dưới decorator @app.route, hàm này sẽ được bảo vệ và không cho phép những user không đăng nhập truy cập. Sau đây là ví dụ với hàm hiển thị cho trang chủ của ứng dụng:
- app/routes.py: decorator @login_required
from flask_login import login_required @app.route('/') @app.route('/index') @login_required def index(): ...
Sau đây là đoạn mã để đọc và xử lý tham số next:
- app/routes.py: Chuyển hướng đến trang tại “next”
from flask import request from werkzeug.urls import url_parse @app.route('/login', methods=['GET', 'POST']) def login(): ... if form.validate_on_submit(): user = User.query.filter_by(username=form.username.data).first() if user is None or not user.check_password(form.password.data): flash('Invalid username or password') return redirect(url_for('login')) login_user(user, remember=form.remember_me.data) next_page = request.args.get('next') if not next_page or url_parse(next_page).netloc != '': next_page = url_for('index') return redirect(next_page) ...
- Nếu URL đăng nhập không có tham số next, user sẽ được chuyển hướng đến trang chủ (/index) theo mặc định.
- Nếu URL đăng nhập có tham số next được gán với giá trị là một đường dẫn tương đối (hay nói cách khác là một URL không có phần domain -tên miền), user sẽ được chuyển hướng đến URL đó.
- Nếu URL đăng nhập có tham số next được gán với giá trị là một URL đầy đủ với cả tên miền, user sẽ được chuyển hướng đến trang chủ của ứng dụng.
Hiển thị user đã đăng nhập trong các template
Bạn còn nhớ trong Phần 2 chúng ta đã tạo ra một user giả để mô phỏng hệ thống đăng nhập user khi thiết kế trang chủ khi chúng ta còn chưa có hệ thống này hay không? Bây giờ chúng ta đã có một hệ thống đăng nhập hoàn chỉnh với các user thật, vì vậy, chúng ta có thể bỏ user giả đó và làm việc với các user thật. Thay vì user giả, chúng ta có thể dùng biến current_user trong các template:- app/templates/index.html: Truyền thông tin về user đang đăng nhập vào template
{% extends "base.html" %} {% block content %} <h1>Hi, {{ current_user.username }}!</h1> {% for post in posts %} <div><p>{{ post.author.username }} : <b>{{ post.body }}</b></p></div> {% endfor %} {% endblock %}
- app/routes.py: Không truyền tham số user đến các template nữa
@app.route('/') @app.route('/index') def index(): ... return render_template("index.html", title='Home Page', posts=posts)
>>> u = User(username='thai', email='thai@example.com') >>> u.set_password('thaipham') >>> db.session.add(u) >>> db.session.commit()
Đăng ký user
Chức năng cuối mà chúng ta sẽ xây dựng trong bài hôm nay là form đăng ký để user mới có thể đăng ký và đăng nhập vào ứng dụng. Chúng ta sẽ cần tạo ra một lớp web form mới trong app/forms.py:- app/forms.py: form đăng ký user
from flask_wtf import FlaskForm from wtforms import StringField, PasswordField, BooleanField, SubmitField from wtforms.validators import ValidationError, DataRequired, Email, EqualTo from app.models import User ... class RegistrationForm(FlaskForm): username = StringField('Username', validators=[DataRequired()]) email = StringField('Email', validators=[DataRequired(), Email()]) password = PasswordField('Password', validators=[DataRequired()]) password2 = PasswordField( 'Repeat Password', validators=[DataRequired(), EqualTo('password')]) submit = SubmitField('Register') def validate_username(self, username): user = User.query.filter_by(username=username.data).first() if user is not None: raise ValidationError('Please use a different username.') def validate_email(self, email): user = User.query.filter_by(email=email.data).first() if user is not None: raise ValidationError('Please use a different email address.')
Tiếp theo, chúng ta cũng tuân theo quy ước chung của các form đăng ký là yêu cầu user nhập mật mã hai lần để tránh tình trạng nhập sai mật mã ngoài ý muốn. Vì vậy, chúng ta dùng hai trường password và password2. Trường password2 cũng sử dụng một biến kiểm tra có sẵn gọi là EqualTo để bảo đảm rằng giá trị nhập vào trường này phải giống như giá trị của trường password đã nhập trước đó.
Và cuối cùng, chúng ta cũng thêm hai hàm mới gọi là validate_username() và validate_email(). Khi chúng ta thêm bất kỳ hàm nào với tên gọi theo mẫu validate_, WTForms sẽ hiểu những hàm này như là những biến kiểm tra dữ liệu tùy biến (custom validator) và sẽ sử dụng chúng cho các trường tương ứng sau khi đã dùng các biến kiểm tra dữ liệu sẵn có. Trong trường hợp này, chúng ta dùng các hàm kiểm tra dữ liệu này để chắc chắn rằng các giá trị username và email đã được user nhập vào không tồn tại trong cơ sở dữ liệu, vì thế các truy vấn dữ liệu phải không tìm được dữ liệu trong bảng User căn cứ trên các giá trị này. Nếu có kết quả trả về, các hàm này sẽ đưa ra ngoại lệ ValidationError. Tham số cho các ngoại lệ Validation là các thông báo lỗi tương ứng và sẽ được hiển thị bên cạnh các trường này khi các dữ liệu nhập vào không đúng.
Để hiển thị form này, chúng ta cần xây dựng một template HTML. Chúng ta sẽ đưa template này vào file app/templates/register.html. Chúng ta cũng viết mã cho template này tương tự như cho form đăng nhập:
- app/templates/register.html: Template cho form đăng ký user
{% extends "base.html" %} {% block content %} <h1>Register</h1> <form action="" method="post"> {{ form.hidden_tag() }} <p> {{ form.username.label }}<br> {{ form.username(size=32) }}<br> {% for error in form.username.errors %} <span style="color: red;">[{{ error }}]</span> {% endfor %} </p> <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.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 %}
- app/templates/login.html: Liên kết đến trang đăng ký
<p>New User? <a href="{{ url_for('register') }}">Click to Register!</a></p>
- app/routes.py: Hàm hiển thị cho form đăng ký user
from app import db from app.forms import RegistrationForm ... @app.route('/register', methods=['GET', 'POST']) def register(): if current_user.is_authenticated: return redirect(url_for('index')) form = RegistrationForm() if form.validate_on_submit(): user = User(username=form.username.data, email=form.email.data) user.set_password(form.password.data) db.session.add(user) db.session.commit() flash('Congratulations, you are now a registered user!') return redirect(url_for('login')) return render_template('register.html', title='Register', form=form)
Với các thay đổi này, user sẽ có thể tạo tài khoản người sử dụng, đăng nhập và đăng xuất. Hãy thử tất cả các tình huống có lỗi để hiểu cách làm việc của mã kiểm tra lỗi mà chúng ta đã thêm vào chương trình. Chúng ta sẽ trở lại với hệ thống xác nhận người dùng và thêm chức năng để user có thể reset lại mật mã trong trường hợp họ quên trong một bài viết sau. Nhưng bây giờ, chúng ta hãy chuyển sang phần khác của ứng dụng.