Flask實作單用戶登入-擴展原有project

擴展之前做的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 classset_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

然後在CSSbase.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,不然會連輸帳密的地方都沒有,除非用戶自己輸入urllogin頁面
# 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>

Flask實作單用戶登入-擴展原有project
https://f88083.github.io/2023/12/06/Flask實作單用戶登入-擴展原有project/
作者
Simon Lai
發布於
2023年12月6日
許可協議