Flask-Security(Flask-Security-Too)

認証ライブラリPythonFlaskセキュリティユーザー管理認可トークン認証二要素認証

認証ライブラリ

Flask-Security(Flask-Security-Too)

概要

Flask-Securityは、Flaskアプリケーションに包括的なセキュリティ機能を迅速に追加するPythonライブラリです。2025年現在、Flask-Security-Tooとして活発に開発が継続されており、バージョン5.6.2が最新版です。認証、認可、ロールベースアクセス制御、トークン認証、二要素認証など、Webアプリケーションに必要なセキュリティ機能を統合的に提供します。OWASPのベストプラクティスに従った設計により、本番環境でも安心して使用できる成熟したライブラリとして、Flask開発者のセキュリティ要件を一手に引き受ける「スイスアーミーナイフ」的存在です。

詳細

Flask-Securityは、Palletsコミュニティエコシステムの一部として維持されており、Flask本体の開発チームからのサポートも受けています。Flask-Loginの上に構築されているため、単純な認証には過剰かもしれませんが、包括的なセキュリティ機能が最初から必要な場合には理想的な選択肢です。

主要機能

  • 多様な認証方式: セッション、Basic HTTP認証、トークン認証、WebAuthn
  • 包括的なユーザー管理: ユーザー登録、ログイン、パスワードリセット、アカウント確認
  • 高度な認可システム: ロールベースアクセス制御(RBAC)、権限管理
  • 多要素認証(MFA): メール、SMS、認証アプリ(TOTP)、WebAuthn対応
  • セキュリティ機能: CSRF保護、パスワード強度検証、アカウントロック
  • 統合認証: OAuth2、OpenID Connect(authlib統合)
  • API対応: RESTful API向けのトークン認証とJSON応答

技術的特徴

Flask-Security-Tooは、放棄されたプロジェクトへの依存を減らし、一般的なユースケースのサポートを内蔵することで、より意見が統一された「バッテリー内蔵」のソリューションを提供します。バージョン5.xでは新しい認証・認可標準の追加が継続されており、Social Auth統合(authlib使用)などの現代的な機能も提供されています。

メリット・デメリット

メリット

  • 包括的機能: 認証に関するほぼすべての要件を単一ライブラリで解決
  • 本番環境実績: 多くの本番環境で実際に使用されている実績
  • OWASP準拠: セキュリティベストプラクティスに基づいた設計
  • 活発な開発: Flask-Security-Tooとして継続的に更新・改善
  • 豊富なカスタマイズ: テンプレート、フォーム、ビューの細かなカスタマイズが可能
  • 現代的標準対応: WebAuthn、OAuth2、OpenID Connectなど最新の認証技術
  • 詳細なドキュメント: 包括的で実用的なドキュメントと豊富なサンプル

デメリット

  • 学習コストの高さ: 豊富な機能ゆえに設定が複雑になりがち
  • オーバーエンジニアリング: 小規模なアプリケーションには過剰な機能
  • 依存関係の多さ: 多機能ゆえに多くの依存ライブラリが必要
  • カスタマイズの複雑さ: 独自要件への対応時に内部構造の理解が必要
  • パフォーマンス影響: 機能の豊富さがアプリケーションの起動時間に影響する可能性

参考ページ

書き方の例

インストールと基本設定

# 依存関係のインストール
# pip install flask-security[common,mfa,fsqla]

import os
from flask import Flask, render_template_string
from flask_sqlalchemy import SQLAlchemy
from flask_security import Security, SQLAlchemyUserDatastore, \
    UserMixin, RoleMixin, auth_required, hash_password
from flask_security.models import fsqla_v3 as fsqla

# Flaskアプリケーションの作成
app = Flask(__name__)
app.config['DEBUG'] = True

# セキュリティ設定
app.config['SECRET_KEY'] = os.environ.get("SECRET_KEY", 'your-secret-key')
app.config['SECURITY_PASSWORD_SALT'] = os.environ.get("SECURITY_PASSWORD_SALT", 'your-password-salt')

# データベース設定
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///app.db'
app.config["SQLALCHEMY_ENGINE_OPTIONS"] = {"pool_pre_ping": True}
app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False

# Cookie設定
app.config["REMEMBER_COOKIE_SAMESITE"] = "strict"
app.config["SESSION_COOKIE_SAMESITE"] = "strict"

# データベース初期化
db = SQLAlchemy(app)
fsqla.FsModels.set_db_info(db)

ユーザーモデルとロール設定

# ロールモデルの定義
class Role(db.Model, fsqla.FsRoleMixin):
    pass

# ユーザーモデルの定義
class User(db.Model, fsqla.FsUserMixin):
    # カスタムフィールドの追加
    first_name = db.Column(db.String(255))
    last_name = db.Column(db.String(255))
    
    # カスタムペイロードの定義
    def get_security_payload(self):
        rv = super().get_security_payload()
        rv["first_name"] = self.first_name
        rv["last_name"] = self.last_name
        return rv

# Flask-Securityの設定
user_datastore = SQLAlchemyUserDatastore(db, User, Role)
security = Security(app, user_datastore)

# データベース初期化
with app.app_context():
    db.create_all()
    
    # デフォルトロールの作成
    if not security.datastore.find_role("admin"):
        security.datastore.create_role(
            name="admin", 
            permissions={"admin-read", "admin-write", "user-read", "user-write"}
        )
    
    if not security.datastore.find_role("user"):
        security.datastore.create_role(
            name="user", 
            permissions={"user-read", "user-write"}
        )
    
    # デフォルトユーザーの作成
    if not security.datastore.find_user(email="[email protected]"):
        security.datastore.create_user(
            email="[email protected]",
            password=hash_password("admin123"),
            roles=["admin"],
            first_name="Admin",
            last_name="User"
        )
    
    db.session.commit()

認証と認可の実装

from flask_security import auth_required, roles_required, permissions_required

# 基本的な認証が必要なルート
@app.route('/')
@auth_required()
def home():
    return render_template_string("Hello {{ current_user.email }}!")

# ロールベースの認可
@app.route('/admin')
@auth_required()
@roles_required('admin')
def admin_panel():
    return render_template_string("Welcome to admin panel, {{ current_user.email }}!")

# 権限ベースの認可
@app.route('/user-data')
@auth_required()
@permissions_required('user-read')
def user_data():
    return render_template_string("User data for {{ current_user.email }}")

# 複数の認証方式を許可
@app.route('/api/data')
@auth_required("token", "session")
def api_data():
    return {"message": "API data", "user": current_user.email}

# カスタム認可エラーハンドリング
from flask import jsonify
import http

class MyForbiddenException(Exception):
    def __init__(self, msg='Not permitted', status=http.HTTPStatus.FORBIDDEN):
        self.info = {'status': status, 'msgs': [msg]}

@security.unauthz_handler
def my_unauthz_handler(func, params):
    raise MyForbiddenException()

@app.errorhandler(MyForbiddenException)
def handle_forbidden(ex):
    return jsonify(ex.info), ex.info['status']

フォームベース認証

# Flask-WTF統合とCSRF保護
import flask_wtf
from flask_wtf.csrf import CSRFProtect

# CSRF保護の設定
app.config["SECURITY_CSRF_COOKIE_NAME"] = "XSRF-TOKEN"
app.config["WTF_CSRF_TIME_LIMIT"] = None
csrf = CSRFProtect(app)

# 条件付きCSRF保護(セッション認証のみ)
app.config["WTF_CSRF_CHECK_DEFAULT"] = False
app.config["SECURITY_CSRF_PROTECT_MECHANISMS"] = ["session", "basic"]

# カスタムログインフォーム
from flask_security.forms import LoginForm
from wtforms import StringField, validators

class ExtendedLoginForm(LoginForm):
    username = StringField('Username', [validators.Optional()])

# セキュリティ設定でカスタムフォームを使用
app.config['SECURITY_LOGIN_FORM'] = ExtendedLoginForm

# テンプレートでの認証状態確認
@app.route('/profile')
def profile():
    return render_template_string("""
    {% if _fs_is_user_authenticated %}
        <h1>Profile for {{ current_user.email }}</h1>
        <p>Role: {{ current_user.roles[0].name if current_user.roles else 'No role' }}</p>
    {% else %}
        <a href="{{ url_for('security.login') }}">Login</a>
    {% endif %}
    """)

APIトークン認証

from flask import request, jsonify

# トークン認証の設定
app.config['SECURITY_TOKEN_AUTHENTICATION_KEY'] = 'auth_token'
app.config['SECURITY_TOKEN_AUTHENTICATION_HEADER'] = 'X-Auth-Token'
app.config['SECURITY_TOKEN_MAX_AGE'] = 3600  # 1時間

# カスタムトークン有効期限設定
def custom_token_expiry(user):
    import time
    # 管理者は24時間、一般ユーザーは1時間
    if user.has_role('admin'):
        return int(time.time()) + 86400
    return int(time.time()) + 3600

app.config['SECURITY_TOKEN_EXPIRE_TIMESTAMP'] = custom_token_expiry

# API エンドポイント
@app.route('/api/login', methods=['POST'])
def api_login():
    """APIログインエンドポイント"""
    data = request.get_json()
    user = security.datastore.find_user(email=data.get('email'))
    
    if user and user.verify_and_update_password(data.get('password')):
        login_user(user)
        token = user.get_auth_token()
        return jsonify({
            'token': token,
            'user': user.get_security_payload()
        })
    
    return jsonify({'error': 'Invalid credentials'}), 401

@app.route('/api/protected')
@auth_required('token')
def protected_api():
    """トークン認証が必要なAPIエンドポイント"""
    return jsonify({
        'message': 'Protected data',
        'user_id': current_user.id,
        'permissions': [p for role in current_user.roles for p in role.permissions]
    })

# クライアントサイドでのトークン使用例(JavaScript)
def get_example_js():
    return """
    // トークンでAPIにアクセス
    function callProtectedAPI(token) {
        return fetch('/api/protected', {
            method: 'GET',
            headers: {
                'X-Auth-Token': token,
                'Content-Type': 'application/json'
            }
        }).then(response => response.json());
    }
    
    // CSRF トークンを含むPOSTリクエスト
    function postWithCSRF(data) {
        return fetch('/api/data', {
            method: 'POST',
            credentials: 'include',
            headers: {
                'Content-Type': 'application/json',
                'X-XSRF-TOKEN': getCookieValue('XSRF-TOKEN')
            },
            body: JSON.stringify(data)
        });
    }
    """

テストとセキュリティ設定

import pytest
from flask import url_for

# テスト設定
@pytest.fixture
def app():
    app.config['TESTING'] = True
    app.config['WTF_CSRF_ENABLED'] = False
    app.config['SECURITY_EMAIL_VALIDATOR_ARGS'] = {"check_deliverability": False}
    
    with app.app_context():
        db.create_all()
        yield app
        db.drop_all()

# 認証テスト
def test_login_required(client):
    """認証が必要なページへのアクセステスト"""
    response = client.get('/')
    assert response.status_code == 302  # リダイレクト
    assert '/login' in response.location

def test_valid_login(client):
    """有効なログインテスト"""
    response = client.post('/login', data={
        'email': '[email protected]',
        'password': 'admin123'
    }, follow_redirects=True)
    assert response.status_code == 200

def test_api_token_auth(client):
    """APIトークン認証テスト"""
    # ログインしてトークンを取得
    login_response = client.post('/api/login', json={
        'email': '[email protected]',
        'password': 'admin123'
    })
    token = login_response.get_json()['token']
    
    # トークンでAPIにアクセス
    api_response = client.get('/api/protected', headers={
        'X-Auth-Token': token
    })
    assert api_response.status_code == 200

def test_role_based_access(client):
    """ロールベースアクセス制御テスト"""
    # 一般ユーザーでログイン
    client.post('/login', data={
        'email': '[email protected]',
        'password': 'user123'
    })
    
    # 管理者ページへのアクセス(403エラーを期待)
    response = client.get('/admin')
    assert response.status_code == 403

# セキュリティ強化設定
app.config.update({
    # パスワード複雑性
    'SECURITY_PASSWORD_LENGTH_MIN': 8,
    'SECURITY_PASSWORD_COMPLEXITY_CHECKER': 'zxcvbn',
    
    # アカウントロック
    'SECURITY_LOGIN_ERROR_VIEW': '/login-error',
    'SECURITY_TRACKABLE': True,
    
    # メール設定
    'SECURITY_EMAIL_VALIDATOR_ARGS': {'check_deliverability': True},
    'SECURITY_CONFIRMABLE': True,
    'SECURITY_RECOVERABLE': True,
    
    # セッション設定
    'PERMANENT_SESSION_LIFETIME': 3600,  # 1時間
    'SESSION_COOKIE_SECURE': True,  # HTTPS必須
    'SESSION_COOKIE_HTTPONLY': True,
    
    # その他のセキュリティ設定
    'SECURITY_FLASH_MESSAGES': True,
    'SECURITY_REDIRECT_BEHAVIOR': 'spa',  # SPA対応
})

if __name__ == '__main__':
    app.run(debug=True)