Flask實作單用戶登入-擴展原有project
最新教程在這裡: Flask 入门教程3.0 (2022/07/16發布)
目標
- 網頁供單人使用
- 輸入帳號密碼驗證後才能進入並使用網頁
前置作業
只要是python
程式碼就寫在app.py
首先定義一下Model class
以儲存帳號密碼,這裡取名為User
- 密碼要存哈希值,因為存明文在資料庫非常危險
User class
繼承Flask-Login
提供的UserMixin
(需要安裝Flask-Login,之後步驟會提到)
繼承UserMixin
可以讓User class
擁有幾個屬性和方法來判斷認證狀態,最常用的是is_authenticated
,可用來判斷用戶是否已經登入,方便頁面的驗證
class User(db.Model, UserMixin):
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(20)) # 用戶名稱
username = db.Column(db.String(20)) # 帳號名稱
password_hash = db.Column(db.String(128)) # Hashed password
def set_password(self, password):
# Create hashed password
self.password_hash = generate_password_hash(password)
def validate_password(self, password):
# Validate password
return check_password_hash(self.password_hash, password)
定義後生成一下資料庫,在env
裡輸入flask shell
,或是python
都可以
>>> from app import db
>>> db.drop_all()
>>> db.create_all()
>>> exit()
另一種辦法是直接寫一個CLI命令如下,執行重新生成資料庫的命令
@app.cli.command() # Register as command
@click.option('--drop', is_flag=True, help='Create after drop.')
# Configure the command for drop and create database
def initdb(drop):
"""Initialize the database."""
if drop:
db.drop_all()
db.create_all()
click.echo('Initialized database.')
使用方法為在terminal輸入(一樣在env裡): flask initdb --drop
成功後會看到: Initialized database
註冊帳號密碼
資料庫建立好之後就可以來註冊帳號密碼了,同樣也是定義一個function來建立
@app.cli.command()
@click.option('--username', prompt=True, help='The username used to login.')
@click.option('--password', prompt=True, hide_input=True, confirmation_prompt=True, help='The password used to login.')
# Create a command to create an admin user
def admin(username, password):
"""Create user."""
db.create_all()
user = User.query.first()
# Update the user if exists
if user is not None:
click.echo('Updating user...')
user.username = username
user.set_password(password)
else:
click.echo('Creating user...')
user = User(username=username, name='Admin')
user.set_password(password)
db.session.add(user)
db.session.commit()
click.echo('Done.')
使用方法也是在terminal操作: flask admin
該方法調用了User class
的set_password()
因此存進資料庫的是哈希值,不是明文
登入
準備工作結束後就要替網頁加上登入功能
先在env安裝
(env) $ pip install flask-login
然後instantiate Flask-login
login_manager = LoginManager(app)
@login_manager.user_loader
def load_user(user_id):
# Return user object by searching on ID
return User.query.get(int(user_id))
顧名思義是根據id加載用戶的方法
登入頁面
- 檢查帳號密碼欄位是否為空
- 驗證帳號密碼是否與資料庫的一致
- 通過後調用
login_user()
(Flask-Login
自帶的方法)登入用戶
@app.route('/login', methods=['GET', 'POST'])
def login():
if request.method == 'POST':
username = request.form['username'] # Get username from form
password = request.form['password'] # Get password from form
if not username or not password:
flash('Invalid username or password')
return redirect(url_for('login'))
user = User.query.first()
if username == user.username and user.validate_password(password):
login_user(user)
flash('Login success')
return redirect(url_for('index'))
# If username or password is wrong
flash('Invalid username or password')
# Back to login page
return redirect(url_for('login'))
return render_template('login.html')
這個方法還使用了flash()
,這是Flask
提供用來印出訊息在網頁上給使用者看到,可以想像成是print
要使用這個功能必須定義SECRET_KEY
,但要記得部署前要改為隨機字串,確保安全性
# Config. secret key
app.config['SECRET_KEY'] = 'dev' # FIXME: Should change to a random string when deploy
然後在CSS
和base.html
檔案加入
.alert {
position: relative;
padding: 7px;
margin: 7px 0;
border: 1px solid transparent;
color: #004085;
background-color: #cce5ff;
border-color: #b8daff;
border-radius: 5px;
}
在{% block body %}
之上
<body>
{% for message in get_flashed_messages() %}
<div class="alert">{{ message }}</div>
{% endfor %}
{% block body %}{% endblock %}
</body>
定義完方法後定義相對應的頁面-login.html
{% extends 'base.html' %}
{% block head %}
<title>Login</title>
{% endblock %}
{% block body %}
<form method="post">
Username<br>
<input type="text" name="username" required><br><br>
Password<br>
<input type="password" name="password" required><br><br>
<input class="btn" type="submit" name="submit" value="Submit">
</form>
{% endblock %}
登出
登出只要簡單幾行,至於按鈕我就放在首頁的最上面,不管美化了
<a href="/logout">Logout</a>
<h1 style="text-align: center;">Watching History</h1>
@app.route('/logout')
@login_required # Protect this route
def logout():
logout_user()
flash('Logout success')
return redirect(url_for('index'))
認證保護
本文目標是只有登入的用戶才能使用該網頁,所以全部頁面都要保護起來,因此要在以下方法都添加上@login_required
delete()
update()
- 以及首頁,改為以下,不用
login_required
,不然會連輸帳密的地方都沒有,除非用戶自己輸入url
到login
頁面
# Route to display the watching history
@app.route("/", methods=["POST", "GET"])
def index():
if request.method == "POST":
# 檢查登入狀態
if not current_user.is_authenticated:
return redirect(url_for('login'))
# 收到資料
history_title = request.form["title"]
history_season = request.form["season"]
history_value = request.form["value"]
history_episode = request.form["episode"]
history_progress = request.form["progress"]
# 資料轉換為WatchingHistory class
new_history = WatchingHistory(
title=history_title,
season=history_season,
value=history_value,
episode=history_episode,
progress=history_progress,
)
try:
# 資料加入
db.session.add(new_history)
db.session.commit()
return redirect("/")
except:
return "There was an issue adding the history"
else:
# 檢查登入狀態
if not current_user.is_authenticated:
flash('Please login to see the history')
return redirect(url_for('login'))
# 獲取所有資料
history = WatchingHistory.query.order_by(WatchingHistory.date_created).all()
# 傳資料到index.html
return render_template("index.html", history=history)
總結
不得不說package真D方便,甚麼都幫你做好了,串接一下,設定一下就搞定了,感謝強大的網友們,以及我參考的教程,讓我沒遇到甚麼困難就實作出登入登出功能。
本文是開發單用戶的登入功能,之後可以往多用戶發展
以下附上本文修改過的檔案,沒放上來就是和這篇一樣
app.py
from datetime import datetime
from flask import Flask, flash, redirect, render_template, request, jsonify, url_for
from flask_sqlalchemy import SQLAlchemy
from flask_login import LoginManager, UserMixin, current_user, login_required, login_user, logout_user
import click
from werkzeug.security import generate_password_hash, check_password_hash
# Init. the Flask app
app = Flask(__name__)
# Config. database
app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///watching-history.db"
# Config. secret key
app.config['SECRET_KEY'] = 'dev' # FIXME: Should change to a random string when deploy
# Init. database
db = SQLAlchemy(app)
# Instantiate login manager
login_manager = LoginManager(app)
@app.cli.command() # Register as command
@click.option('--drop', is_flag=True, help='Create after drop.')
# Configure the command for drop and create database
def initdb(drop):
"""Initialize the database."""
if drop:
db.drop_all()
db.create_all()
click.echo('Initialized database.')
@app.cli.command()
@click.option('--username', prompt=True, help='The username used to login.')
@click.option('--password', prompt=True, hide_input=True, confirmation_prompt=True, help='The password used to login.')
# Create a command to create an admin user
def admin(username, password):
"""Create user."""
db.create_all()
user = User.query.first()
# Update the user if exists
if user is not None:
click.echo('Updating user...')
user.username = username
user.set_password(password)
else:
click.echo('Creating user...')
user = User(username=username, name='Admin')
user.set_password(password)
db.session.add(user)
db.session.commit()
click.echo('Done.')
# Create a model for database
class WatchingHistory(db.Model):
id = db.Column(db.Integer, primary_key=True)
title = db.Column(db.String(200), nullable=False)
season = db.Column(db.String(10), nullable=False)
value = db.Column(db.Integer, nullable=False)
episode = db.Column(db.Integer, nullable=False)
progress = db.Column(db.String(10), nullable=False)
date_created = db.Column(db.DateTime, default=datetime.utcnow)
# Return a string when create a new element
def __repr__(self):
return "<Watching History %r>" % self.id
# User class
class User(db.Model, UserMixin):
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(20))
username = db.Column(db.String(20))
password_hash = db.Column(db.String(128)) # Hashed password
def set_password(self, password):
# Create hashed password
self.password_hash = generate_password_hash(password)
def validate_password(self, password):
# Validate password
return check_password_hash(self.password_hash, password)
@login_manager.user_loader
def load_user(user_id):
# Return user object by searching on ID or None
return User.query.get(int(user_id))
@app.route('/login', methods=['GET', 'POST'])
def login():
if request.method == 'POST':
username = request.form['username'] # Get username from form
password = request.form['password'] # Get password from form
if not username or not password:
flash('Invalid username or password')
return redirect(url_for('login'))
user = User.query.first()
if username == user.username and user.validate_password(password):
login_user(user)
flash('Login success')
return redirect(url_for('index'))
# If username or password is wrong
flash('Invalid username or password')
# Back to login page
return redirect(url_for('login'))
return render_template('login.html')
@app.route('/logout')
@login_required # Protect this route
def logout():
logout_user()
flash('Logout success')
return redirect(url_for('index'))
# Route to display the watching history
@app.route("/", methods=["POST", "GET"])
def index():
if request.method == "POST":
# 檢查登入狀態
if not current_user.is_authenticated:
return redirect(url_for('login'))
# 收到資料
history_title = request.form["title"]
history_season = request.form["season"]
history_value = request.form["value"]
history_episode = request.form["episode"]
history_progress = request.form["progress"]
# 資料轉換為WatchingHistory class
new_history = WatchingHistory(
title=history_title,
season=history_season,
value=history_value,
episode=history_episode,
progress=history_progress,
)
try:
# 資料加入
db.session.add(new_history)
db.session.commit()
return redirect("/")
except:
return "There was an issue adding the history"
else:
# 檢查登入狀態
if not current_user.is_authenticated:
flash('Please login to see the history')
return redirect(url_for('login'))
# 獲取所有資料
history = WatchingHistory.query.order_by(WatchingHistory.date_created).all()
# 傳資料到index.html
return render_template("index.html", history=history)
@app.route('/delete/<int:id>')
@login_required
def delete(id):
# 從資料庫取得該task
history_to_delete = WatchingHistory.query.get_or_404(id)
try:
# 刪除該task
db.session.delete(history_to_delete)
db.session.commit()
return redirect('/')
except:
return 'There was an issue deleting the history'
@app.route('/update/<int:id>', methods=['GET', 'POST'])
@login_required
def update(id):
# 從資料庫取得該history
history = WatchingHistory.query.get_or_404(id)
if request.method == 'POST':
# 取得用戶輸入的資料
history.title = request.form['title']
history.season = request.form['season']
history.value = request.form['value']
history.episode = request.form['episode']
history.progress = request.form['progress']
try:
# 更新資料庫
db.session.commit()
return redirect('/')
except:
return 'There was an issue updating your history'
else:
return render_template('update.html', history=history)
if __name__ == "__main__":
app.run(debug=True)
login.html
{% extends 'base.html' %}
{% block head %}
<title>Login</title>
{% endblock %}
{% block body %}
<form method="post">
Username<br>
<input type="text" name="username" required><br><br>
Password<br>
<!-- 密码输入框的 type 属性使用 password,会将输入值显示为圆点 -->
<input type="password" name="password" required><br><br>
<input class="btn" type="submit" name="submit" value="Submit">
</form>
{% endblock %}
index.html
{% extends 'base.html' %}
{% block head %}
<title>Watching History</title>
{% endblock %}
{% block body %}
<a href="/logout">Logout</a>
<h1 style="text-align: center;">Watching History</h1>
<table border="1" id="watching_history">
<tr>
<th>Title</th>
<th>Season</th>
<th>Episode</th>
<th>Progress</th>
</tr>
{% for entry in history %}
<tr>
<td>{{ entry.title }}</td>
{% if entry.season != "無" %}
<td>第{{ entry.value }}{{ entry.season}}</td>
{% else %}
<td>無</td>
{% endif %}
<td>{{ entry.episode}}</td>
<td>{{ entry.progress }}</td>
<td>
<a href="/delete/{{entry.id}}">Delete</a>
<br>
<a href="/update/{{entry.id}}">Update</a>
</td>
</tr>
{% endfor %}
</table>
<h2>Add New Entry</h2>
<form action="/" method="POST">
<label for="title">Title:</label>
<input type="text" name="title" required><br>
<label for="season">Season:</label>
<select name="season" id="seasonSelect">
<option value="季">季</option>
<option value="卷">卷</option>
<option value="無">無</option>
</select><br>
<label for="value" id="valueLabel">Value:</label>
<input type="number" name="value" id="valueInput" value="0" required><br>
<label for="episode">Episode:</label>
<input type="number" name="episode" id="episodeInput" required><br>
<label for="progress">Progress:</label>
<input type="text" name="progress" required><br>
<input type="submit" value="新增">
</form>
<script>
const seasonSelect = document.getElementById('seasonSelect');
const valueLabel = document.getElementById('valueLabel');
const valueInput = document.getElementById('valueInput');
const episodeInput = document.getElementById('episodeInput');
seasonSelect.addEventListener('change', function() {
if (seasonSelect.value === '無') {
valueLabel.style.visibility = 'hidden'; // Hide the label
valueInput.style.visibility = 'hidden'; // Hide the input
} else {
valueLabel.style.visibility = 'visible'; // Show the label
valueInput.style.visibility = 'visible'; // Show the input
}
});
// Trigger the change event initially in case there's a default value selected
seasonSelect.dispatchEvent(new Event('change'));
</script>
{% endblock %}
base.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="{{ url_for('static', filename='css/main.css') }}">
{% block head %}{% endblock %}
</head>
<body>
{% for message in get_flashed_messages() %}
<div class="alert">{{ message }}</div>
{% endfor %}
{% block body %}{% endblock %}
</body>
</html>