文章

Flask - 密碼哈希

目標

  • 安裝並配置 Flask-Bcrypt
  • 將密碼存儲為哈希值
  • 更新認證邏輯以驗證哈希密碼

步驟

  1. 準備環境

    • 繼續使用 flask_api/ 項目結構,激活虛擬環境:
      1
      2
      
      # Windows: flask_api_env\Scripts\activate
      # macOS/Linux: source flask_api_env/bin/activate
      
    • 安裝 Flask-Bcrypt:
      1
      
      pip install flask-bcrypt
      
  2. 配置 Flask-Bcrypt

    • 修改 app/init.py,初始化 Bcrypt:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      33
      34
      35
      36
      37
      38
      39
      40
      41
      42
      43
      44
      45
      46
      47
      48
      49
      50
      51
      52
      53
      54
      55
      56
      57
      58
      59
      60
      61
      62
      63
      64
      65
      66
      67
      68
      69
      70
      
      from flask import Flask, jsonify, g
      from flask_sqlalchemy import SQLAlchemy
      from flask_marshmallow import Marshmallow
      from flask_bcrypt import Bcrypt  # 新增
      import jwt
      from functools import wraps
      from .routes.v1.todos import todos_bp as todos_v1_bp
      from .routes.v1.users import users_bp as users_v1_bp
      from .routes.v1.posts import posts_bp as posts_v1_bp
      from .routes.v2.todos import todos_bp as todos_v2_bp
      from .routes.v2.posts import posts_bp as posts_v2_bp
      from .config import config_map
      import os
      
      db = SQLAlchemy()
      ma = Marshmallow()
      bcrypt = Bcrypt()  # 初始化 Bcrypt
      
      def create_app():
          app = Flask(__name__)
          env = os.getenv('FLASK_ENV', 'development')
          app.config.from_object(config_map[env])
          db.init_app(app)
          ma.init_app(app)
          bcrypt.init_app(app)  # 初始化 Bcrypt
      
          app.register_blueprint(todos_v1_bp, url_prefix='/api/v1')
          app.register_blueprint(users_v1_bp, url_prefix='/api/v1')
          app.register_blueprint(posts_v1_bp, url_prefix='/api/v1')
          app.register_blueprint(todos_v2_bp, url_prefix='/api/v2')
          app.register_blueprint(posts_v2_bp, url_prefix='/api/v2')
      
          @app.errorhandler(404)
          def not_found(error):
              return jsonify({'error': 'Not Found', 'message': str(error)}), 404
      
          @app.errorhandler(400)
          def bad_request(error):
              return jsonify({'error': 'Bad Request', 'message': str(error)}), 400
      
          @app.errorhandler(500)
          def internal_error(error):
              return jsonify({'error': 'Internal Server Error', 'message': 'Something went wrong on our end'}), 500
      
          with app.app_context():
              db.create_all()
      
          return app
      
      def login_required(f):
          @wraps(f)
          def decorated_function(*args, **kwargs):
              from .models import User
              token = request.headers.get('Authorization')
              if not token:
                  abort(401, description='Missing token')
              try:
                  if token.startswith('Bearer '):
                      token = token[7:]
                  data = jwt.decode(token, app.config['SECRET_KEY'], algorithms=['HS256'])
                  user = User.query.get(data['user_id'])
                  if not user:
                      abort(401, description='Invalid token')
                  g.current_user = user
              except jwt.ExpiredSignatureError:
                  abort(401, description='Token has expired')
              except jwt.InvalidTokenError:
                  abort(401, description='Invalid token')
              return f(*args, **kwargs)
          return decorated_function
      
  3. 更新模型

    • 修改 app/models.py,調整密碼字段為哈希值:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      
      from . import db, bcrypt
      from datetime import datetime
      
      class User(db.Model):
          __tablename__ = 'users'
          id = db.Column(db.Integer, primary_key=True)
          username = db.Column(db.String(50), unique=True, nullable=False)
          password_hash = db.Column(db.String(128), nullable=False)  # 改為 password_hash
          posts = db.relationship('Post', backref='user', lazy=True)
      
          def set_password(self, password):
              self.password_hash = bcrypt.generate_password_hash(password).decode('utf-8')
      
          def check_password(self, password):
              return bcrypt.check_password_hash(self.password_hash, password)
      
      class Post(db.Model):
          __tablename__ = 'posts'
          id = db.Column(db.Integer, primary_key=True)
          title = db.Column(db.String(100), nullable=False)
          content = db.Column(db.Text, nullable=False)
          created_at = db.Column(db.DateTime, default=datetime.utcnow)
          user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
          category = db.Column(db.String(50), default='general')
      
    • 代碼解釋

      • set_password:將明文密碼轉為哈希值。
      • check_password:驗證輸入密碼是否與哈希值匹配。
      • 字段改為 password_hash,長度設為 128 以容納哈希。
  4. 更新用戶路由

    • 修改 app/routes/v1/users.py,使用哈希密碼:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      33
      34
      35
      36
      37
      38
      39
      40
      41
      42
      43
      44
      
      from flask import Blueprint, jsonify, request, abort
      from ...models import User
      from ... import db
      from ...schemas import user_schema, users_schema
      import jwt
      import datetime
      
      users_bp = Blueprint('users_v1', __name__)
      
      @users_bp.route('/users', methods=['GET'])
      def get_users():
          users = User.query.all()
          return jsonify({'users': users_schema.dump(users)})
      
      @users_bp.route('/users', methods=['POST'])
      def create_user():
          if not request.is_json:
              abort(400, description='Request must be JSON')
          data = request.get_json()
          if 'username' not in data or 'password' not in data:
              abort(400, description='Missing username or password')
          if User.query.filter_by(username=data['username']).first():
              abort(400, description='Username already exists')
          user = User(username=data['username'])
          user.set_password(data['password'])  # 設置哈希密碼
          db.session.add(user)
          db.session.commit()
          return jsonify(user_schema.dump(user)), 201
      
      @users_bp.route('/login', methods=['POST'])
      def login():
          if not request.is_json:
              abort(400, description='Request must be JSON')
          data = request.get_json()
          if 'username' not in data or 'password' not in data:
              abort(400, description='Missing username or password')
          user = User.query.filter_by(username=data['username']).first()
          if not user or not user.check_password(data['password']):
              abort(401, description='Invalid credentials')
          token = jwt.encode({
              'user_id': user.id,
              'exp': datetime.datetime.utcnow() + datetime.timedelta(hours=24)
          }, users_bp.app.config['SECRET_KEY'], algorithm='HS256')
          return jsonify({'token': token})
      
  5. 運行應用

    • 刪除舊的 blog.db(因表結構改變),運行:
      1
      
      python run.py
      
  6. 測試 API

    • 使用 Postman 測試:
      • POST /api/v1/users
        • Body:{"username": "alice", "password": "1234"}
        • 預期響應:{"id": 1, "username": "alice", "posts": []}
        • 檢查數據庫:password_hash 應為哈希值而非 “1234”。
      • POST /api/v1/login
        • Body:{"username": "alice", "password": "1234"}
        • 預期響應:{"token": "..."}
      • POST /api/v1/login(錯誤密碼):
        • Body:{"username": "alice", "password": "wrong"}
        • 預期響應:401
      • POST /api/v1/posts
        • Headers:Authorization: Bearer <token>
        • Body:{"title": "My Post", "content": "Hello"}
        • 預期響應:201
  7. 作業

    • 在 v2 的 posts 路由中應用相同的密碼哈希和 JWT 認證。
    • 添加一個端點 PUT /api/v1/users/<int:user_id>/password,允許認證用戶更改自己的密碼。

注意事項

  • 表結構改變後需重建數據庫。
  • bcrypt 生成的哈希值較長,確保數據庫字段長度足夠(這裡設為 128)。
  • 生產環境應使用更強的 SECRET_KEY

本文章以 CC BY 4.0 授權