Flask-Login

認証ライブラリFlask-LoginFlaskPythonセッション管理ユーザー認証Webアプリケーションログイン機能

認証ライブラリ

Flask-Login

概要

Flask-Loginは、Flaskアプリケーション用のシンプルで軽量なユーザーセッション管理ライブラリです。2025年現在も継続的にメンテナンスされ、Flaskアプリケーションでの基本的な認証機能として広く使用されています。複雑な認証機能は提供せず、ユーザーのログイン、ログアウト、セッション記憶機能に特化したミニマルな設計が特徴です。UserMixinクラス、login_required装飾子、current_userプロキシなどの直感的なAPIにより、Flask初心者でも簡単にユーザー認証システムを構築できます。

詳細

Flask-Loginは「何をするか」よりも「どのようにするか」に焦点を当てた設計哲学を持ちます。認証ロジック(パスワード検証、ユーザー登録など)は開発者に委ね、セッション管理機能のみを提供することで高い柔軟性を実現します。セッション保護機能、remember me機能、カスタムユーザーローダー、リクエストローダー等の高度な機能もサポートし、セキュリティ要件に応じた実装が可能です。Flask固有の設計により、Flask生態系(WTForms、SQLAlchemy等)との統合も非常にスムーズです。

主な特徴

  • 軽量設計: 必要最小限の機能に絞った高速なセッション管理
  • Flask統合: Flask生態系との完璧な統合とシンプルな設定
  • 柔軟性: 認証ロジックを開発者に委ねる設計による高いカスタマイズ性
  • セキュリティ: セッション保護、CSRF対策、remember me機能を標準装備
  • テスト対応: FlaskLoginClientによる自動化テストサポート
  • プロダクション実績: 多くのFlaskアプリケーションでの実証済み安定性

メリット・デメリット

メリット

  • Flaskアプリケーションでの認証実装として定番の安定したライブラリ
  • ミニマルなAPIによる学習コストの低さと理解のしやすさ
  • 認証ロジックの自由度が高く、ビジネス要件に柔軟に対応可能
  • Flask生態系(WTForms、SQLAlchemy等)との優れた親和性
  • 軽量設計によるオーバーヘッドの少なさとパフォーマンスの良さ
  • 長年の実績による信頼性と安定した動作保証

デメリット

  • 基本機能のみのため、複雑な認証要件には追加実装が必要
  • OAuth、OpenID Connect等の外部認証は別途実装が必要
  • 多要素認証(MFA)などの高度なセキュリティ機能は未対応
  • 大規模アプリケーションでの拡張性に制限がある
  • セッションベースのためスケールアウトに制約がある
  • Flask専用でフレームワーク間の移行が困難

参考ページ

書き方の例

インストールと基本設定

# Flask-Loginのインストール
pip install Flask-Login

# 関連パッケージのインストール
pip install Flask
pip install Flask-WTF    # フォーム処理
pip install Flask-SQLAlchemy  # データベース
pip install Werkzeug     # パスワードハッシュ化
# app.py - Flask-Login基本設定
import flask
import flask_login
from werkzeug.security import generate_password_hash, check_password_hash

# Flaskアプリケーション初期化
app = flask.Flask(__name__)
app.secret_key = 'your-secret-key-change-this'  # 本番では環境変数から取得

# Flask-Login初期化
login_manager = flask_login.LoginManager()
login_manager.init_app(app)

# ログインが必要なページへの未認証アクセス時の設定
login_manager.login_view = 'login'
login_manager.login_message = 'ログインが必要です。'
login_manager.login_message_category = 'info'

# セッション保護設定(strong: 強固、basic: 基本、None: 無効)
login_manager.session_protection = 'strong'

# remember me機能のカスタマイズ
login_manager.refresh_view = 'reauthenticate'
login_manager.needs_refresh_message = '安全のため、再認証が必要です。'
login_manager.needs_refresh_message_category = 'info'

ユーザーモデルとUserMixin

# models.py - ユーザーモデル定義
from flask_login import UserMixin
from werkzeug.security import generate_password_hash, check_password_hash
from datetime import datetime

class User(UserMixin):
    """Flask-Login用ユーザーモデル"""
    
    def __init__(self, user_id, email, password_hash, name=None, is_active=True):
        self.id = user_id
        self.email = email
        self.password_hash = password_hash
        self.name = name
        self.active = is_active
        self.created_at = datetime.utcnow()
        self.last_login = None
        
    def check_password(self, password):
        """パスワード検証"""
        return check_password_hash(self.password_hash, password)
    
    def set_password(self, password):
        """パスワード設定"""
        self.password_hash = generate_password_hash(password)
    
    def is_active(self):
        """アクティブユーザーかチェック"""
        return self.active
    
    def is_authenticated(self):
        """認証済みユーザーかチェック"""
        return True
    
    def is_anonymous(self):
        """匿名ユーザーかチェック"""
        return False
    
    def get_id(self):
        """ユーザーIDを文字列で返す"""
        return str(self.id)
    
    def update_last_login(self):
        """最終ログイン時刻を更新"""
        self.last_login = datetime.utcnow()
    
    def __repr__(self):
        return f'<User {self.email}>'

# シンプルなインメモリデータベース(実際の開発ではSQLAlchemy等を使用)
users_db = {
    '1': User('1', '[email protected]', generate_password_hash('admin123'), 'Administrator'),
    '2': User('2', '[email protected]', generate_password_hash('user123'), 'Regular User'),
    '3': User('3', '[email protected]', generate_password_hash('demo123'), 'Demo User'),
}

def create_user(email, password, name):
    """新規ユーザー作成"""
    user_id = str(len(users_db) + 1)
    password_hash = generate_password_hash(password)
    user = User(user_id, email, password_hash, name)
    users_db[user_id] = user
    return user

def get_user_by_email(email):
    """メールアドレスでユーザー検索"""
    for user in users_db.values():
        if user.email == email:
            return user
    return None

def authenticate_user(email, password):
    """ユーザー認証"""
    user = get_user_by_email(email)
    if user and user.check_password(password):
        user.update_last_login()
        return user
    return None

ユーザーローダーとリクエストローダー

# auth.py - 認証関連設定
import base64
from flask import request, g
from flask_login import user_loaded_from_request

@login_manager.user_loader
def load_user(user_id):
    """セッションからユーザーIDでユーザーを取得"""
    return users_db.get(user_id)

@login_manager.request_loader
def load_user_from_request(request):
    """リクエストからユーザーを取得(API認証用)"""
    
    # 1. API KeyをURLパラメータから取得
    api_key = request.args.get('api_key')
    if api_key:
        user = get_user_by_api_key(api_key)
        if user:
            return user
    
    # 2. Basic認証ヘッダーから取得
    auth_header = request.headers.get('Authorization')
    if auth_header and auth_header.startswith('Basic '):
        try:
            # Basic認証の解析
            credentials = base64.b64decode(
                auth_header.replace('Basic ', '', 1)
            ).decode('utf-8')
            email, password = credentials.split(':', 1)
            
            # ユーザー認証
            user = authenticate_user(email, password)
            if user:
                return user
        except (ValueError, TypeError):
            pass
    
    # 3. Bearer token認証(JWTトークン等)
    if auth_header and auth_header.startswith('Bearer '):
        token = auth_header.replace('Bearer ', '', 1)
        user = get_user_by_token(token)
        if user:
            return user
    
    return None

@user_loaded_from_request.connect
def user_loaded_from_request(app, user=None):
    """リクエストからユーザーがロードされた時のシグナル"""
    g.login_via_request = True

def get_user_by_api_key(api_key):
    """API Keyでユーザー検索(簡易実装)"""
    # 実際の実装では専用のAPI Keyテーブルを使用
    api_keys = {
        'admin-api-key-123': '1',
        'user-api-key-456': '2'
    }
    user_id = api_keys.get(api_key)
    return users_db.get(user_id) if user_id else None

def get_user_by_token(token):
    """Tokenでユーザー検索(JWT等)"""
    # 実際の実装ではJWTライブラリを使用してトークンを検証
    # ここでは簡易実装
    if token == 'valid-jwt-token':
        return users_db.get('1')
    return None

認証ビューとフォーム処理

# views.py - 認証関連ビュー
from flask import render_template, request, redirect, url_for, flash, jsonify
from flask_login import login_user, logout_user, login_required, current_user
from werkzeug.urls import url_parse

@app.route('/')
def index():
    """ホームページ"""
    return render_template('index.html')

@app.route('/login', methods=['GET', 'POST'])
def login():
    """ログインページ"""
    if current_user.is_authenticated:
        return redirect(url_for('dashboard'))
    
    if request.method == 'POST':
        email = request.form.get('email')
        password = request.form.get('password')
        remember = bool(request.form.get('remember'))
        
        if not email or not password:
            flash('メールアドレスとパスワードを入力してください。', 'error')
            return render_template('login.html')
        
        # ユーザー認証
        user = authenticate_user(email, password)
        if user:
            # ログイン成功
            login_user(user, remember=remember)
            flash(f'ようこそ、{user.name}さん!', 'success')
            
            # next パラメータの処理(リダイレクト攻撃対策)
            next_page = request.args.get('next')
            if next_page:
                # 安全なURLかチェック(同一ドメイン内のみ許可)
                parsed_url = url_parse(next_page)
                if parsed_url.netloc == '' or parsed_url.netloc == request.host:
                    return redirect(next_page)
            
            return redirect(url_for('dashboard'))
        else:
            flash('メールアドレスまたはパスワードが正しくありません。', 'error')
    
    return render_template('login.html')

@app.route('/logout')
@login_required
def logout():
    """ログアウト"""
    user_name = current_user.name
    logout_user()
    flash(f'{user_name}さん、ログアウトしました。', 'info')
    return redirect(url_for('index'))

@app.route('/register', methods=['GET', 'POST'])
def register():
    """ユーザー登録"""
    if current_user.is_authenticated:
        return redirect(url_for('dashboard'))
    
    if request.method == 'POST':
        email = request.form.get('email')
        password = request.form.get('password')
        confirm_password = request.form.get('confirm_password')
        name = request.form.get('name')
        
        # バリデーション
        if not all([email, password, confirm_password, name]):
            flash('すべての項目を入力してください。', 'error')
            return render_template('register.html')
        
        if password != confirm_password:
            flash('パスワードが一致しません。', 'error')
            return render_template('register.html')
        
        if len(password) < 6:
            flash('パスワードは6文字以上で入力してください。', 'error')
            return render_template('register.html')
        
        # 既存ユーザーチェック
        if get_user_by_email(email):
            flash('このメールアドレスは既に登録されています。', 'error')
            return render_template('register.html')
        
        # ユーザー作成
        user = create_user(email, password, name)
        login_user(user)
        flash(f'{user.name}さん、登録が完了しました!', 'success')
        return redirect(url_for('dashboard'))
    
    return render_template('register.html')

@app.route('/dashboard')
@login_required
def dashboard():
    """ダッシュボード(ログイン必須)"""
    return render_template('dashboard.html', user=current_user)

@app.route('/profile')
@login_required
def profile():
    """プロファイルページ"""
    return render_template('profile.html', user=current_user)

高度な認証機能とセキュリティ

# advanced_auth.py - 高度な認証機能
from flask import session, g
from flask_login import fresh_login_required, confirm_login
from flask.sessions import SecureCookieSessionInterface
from datetime import datetime, timedelta
import secrets

# カスタムセッションインターフェース(API用)
class CustomSessionInterface(SecureCookieSessionInterface):
    """API リクエスト時のセッション作成を防ぐ"""
    def save_session(self, *args, **kwargs):
        if g.get('login_via_request'):
            return
        return super().save_session(*args, **kwargs)

app.session_interface = CustomSessionInterface()

@app.route('/admin')
@fresh_login_required
def admin():
    """管理者ページ(新鮮なログインが必要)"""
    return render_template('admin.html')

@app.route('/reauthenticate', methods=['GET', 'POST'])
@login_required
def reauthenticate():
    """再認証ページ"""
    if request.method == 'POST':
        password = request.form.get('password')
        
        if current_user.check_password(password):
            confirm_login()
            flash('再認証が完了しました。', 'success')
            next_page = request.args.get('next', url_for('admin'))
            return redirect(next_page)
        else:
            flash('パスワードが正しくありません。', 'error')
    
    return render_template('reauthenticate.html')

@login_manager.unauthorized_handler
def unauthorized():
    """未認証ユーザーへのカスタム処理"""
    if request.blueprint == 'api':
        # API エンドポイントの場合はJSON で401を返す
        return jsonify({'error': 'Authentication required'}), 401
    
    # Web ページの場合はログインページにリダイレクト
    flash('このページにアクセスするにはログインが必要です。', 'warning')
    return redirect(url_for('login', next=request.url))

@login_manager.needs_refresh_handler
def refresh():
    """再認証が必要な場合の処理"""
    return redirect(url_for('reauthenticate', next=request.url))

# セッション管理のヘルパー関数
def extend_session():
    """セッション期限を延長"""
    session.permanent = True
    app.permanent_session_lifetime = timedelta(hours=1)

def create_csrf_token():
    """CSRF トークン生成"""
    if 'csrf_token' not in session:
        session['csrf_token'] = secrets.token_hex(16)
    return session['csrf_token']

def validate_csrf_token(token):
    """CSRF トークン検証"""
    return session.get('csrf_token') == token

# API 認証用デコレータ
from functools import wraps

def api_key_required(f):
    """API Key 認証デコレータ"""
    @wraps(f)
    def decorated_function(*args, **kwargs):
        api_key = request.headers.get('X-API-Key') or request.args.get('api_key')
        
        if not api_key:
            return jsonify({'error': 'API key required'}), 401
        
        user = get_user_by_api_key(api_key)
        if not user:
            return jsonify({'error': 'Invalid API key'}), 401
        
        g.current_user = user
        return f(*args, **kwargs)
    
    return decorated_function

@app.route('/api/profile')
@api_key_required
def api_profile():
    """API: プロファイル取得"""
    user = g.current_user
    return jsonify({
        'id': user.id,
        'email': user.email,
        'name': user.name,
        'last_login': user.last_login.isoformat() if user.last_login else None
    })

@app.route('/api/data')
@login_required  # セッション認証
def api_data():
    """API: データ取得(セッション認証)"""
    return jsonify({
        'message': 'Authenticated via session',
        'user': current_user.email,
        'data': ['item1', 'item2', 'item3']
    })

テンプレートとフロントエンド統合

<!-- templates/base.html - ベーステンプレート -->
<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>{% block title %}Flask-Login Demo{% endblock %}</title>
    <link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css" rel="stylesheet">
</head>
<body>
    <!-- ナビゲーションバー -->
    <nav class="navbar navbar-expand-lg navbar-dark bg-dark">
        <div class="container">
            <a class="navbar-brand" href="{{ url_for('index') }}">Flask App</a>
            
            <div class="navbar-nav ms-auto">
                {% if current_user.is_authenticated %}
                    <span class="navbar-text me-3">
                        こんにちは、{{ current_user.name }}さん
                    </span>
                    <a class="nav-link" href="{{ url_for('dashboard') }}">ダッシュボード</a>
                    <a class="nav-link" href="{{ url_for('profile') }}">プロファイル</a>
                    <a class="nav-link" href="{{ url_for('logout') }}">ログアウト</a>
                {% else %}
                    <a class="nav-link" href="{{ url_for('login') }}">ログイン</a>
                    <a class="nav-link" href="{{ url_for('register') }}">登録</a>
                {% endif %}
            </div>
        </div>
    </nav>

    <!-- メインコンテンツ -->
    <div class="container mt-4">
        <!-- フラッシュメッセージ -->
        {% with messages = get_flashed_messages(with_categories=true) %}
            {% if messages %}
                {% for category, message in messages %}
                    <div class="alert alert-{{ 'danger' if category == 'error' else category }} alert-dismissible fade show" role="alert">
                        {{ message }}
                        <button type="button" class="btn-close" data-bs-dismiss="alert"></button>
                    </div>
                {% endfor %}
            {% endif %}
        {% endwith %}

        {% block content %}{% endblock %}
    </div>

    <script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>

<!-- templates/login.html - ログインページ -->
{% extends "base.html" %}

{% block title %}ログイン - Flask-Login Demo{% endblock %}

{% block content %}
<div class="row justify-content-center">
    <div class="col-md-6">
        <div class="card">
            <div class="card-header">
                <h4 class="mb-0">ログイン</h4>
            </div>
            <div class="card-body">
                <form method="POST">
                    <div class="mb-3">
                        <label for="email" class="form-label">メールアドレス</label>
                        <input type="email" class="form-control" id="email" name="email" required>
                    </div>
                    
                    <div class="mb-3">
                        <label for="password" class="form-label">パスワード</label>
                        <input type="password" class="form-control" id="password" name="password" required>
                    </div>
                    
                    <div class="mb-3 form-check">
                        <input type="checkbox" class="form-check-input" id="remember" name="remember">
                        <label class="form-check-label" for="remember">ログイン状態を保持する</label>
                    </div>
                    
                    <button type="submit" class="btn btn-primary w-100">ログイン</button>
                </form>
                
                <div class="text-center mt-3">
                    <a href="{{ url_for('register') }}">アカウントをお持ちでない方は登録</a>
                </div>
            </div>
        </div>
    </div>
</div>
{% endblock %}

<!-- templates/dashboard.html - ダッシュボード -->
{% extends "base.html" %}

{% block title %}ダッシュボード - Flask-Login Demo{% endblock %}

{% block content %}
<h1>ダッシュボード</h1>

<div class="row">
    <div class="col-md-8">
        <div class="card">
            <div class="card-header">
                <h5>ユーザー情報</h5>
            </div>
            <div class="card-body">
                <table class="table">
                    <tr>
                        <th>ユーザーID:</th>
                        <td>{{ user.id }}</td>
                    </tr>
                    <tr>
                        <th>名前:</th>
                        <td>{{ user.name }}</td>
                    </tr>
                    <tr>
                        <th>メールアドレス:</th>
                        <td>{{ user.email }}</td>
                    </tr>
                    <tr>
                        <th>最終ログイン:</th>
                        <td>{{ user.last_login.strftime('%Y-%m-%d %H:%M:%S') if user.last_login else '初回ログイン' }}</td>
                    </tr>
                    <tr>
                        <th>作成日:</th>
                        <td>{{ user.created_at.strftime('%Y-%m-%d %H:%M:%S') }}</td>
                    </tr>
                </table>
            </div>
        </div>
    </div>
    
    <div class="col-md-4">
        <div class="card">
            <div class="card-header">
                <h5>クイックアクション</h5>
            </div>
            <div class="card-body">
                <div class="d-grid gap-2">
                    <a href="{{ url_for('profile') }}" class="btn btn-outline-primary">プロファイル編集</a>
                    <a href="{{ url_for('admin') }}" class="btn btn-outline-warning">管理画面</a>
                    <a href="{{ url_for('logout') }}" class="btn btn-outline-danger">ログアウト</a>
                </div>
            </div>
        </div>
    </div>
</div>
{% endblock %}

テストとフラスクテストクライアント

# test_auth.py - Flask-Login テスト
import unittest
from flask_login import FlaskLoginClient
from app import app, users_db, create_user

class AuthTestCase(unittest.TestCase):
    
    def setUp(self):
        """テスト前の準備"""
        app.config['TESTING'] = True
        app.config['WTF_CSRF_ENABLED'] = False
        app.test_client_class = FlaskLoginClient
        self.client = app.test_client()
        
        # テスト用ユーザー作成
        self.test_user = create_user('[email protected]', 'testpass', 'Test User')
    
    def tearDown(self):
        """テスト後のクリーンアップ"""
        # テストユーザーを削除
        if self.test_user.id in users_db:
            del users_db[self.test_user.id]
    
    def test_login_logout(self):
        """ログイン・ログアウトテスト"""
        # ログインテスト
        response = self.client.post('/login', data={
            'email': '[email protected]',
            'password': 'testpass'
        }, follow_redirects=True)
        
        self.assertEqual(response.status_code, 200)
        self.assertIn('ダッシュボード', response.get_data(as_text=True))
        
        # ログアウトテスト
        response = self.client.get('/logout', follow_redirects=True)
        self.assertEqual(response.status_code, 200)
        self.assertIn('ログアウトしました', response.get_data(as_text=True))
    
    def test_login_required(self):
        """ログイン必須ページのテスト"""
        response = self.client.get('/dashboard')
        self.assertEqual(response.status_code, 302)  # リダイレクト
        
        # ログイン後のアクセス
        with self.client.session_transaction() as sess:
            # セッションに直接ユーザーIDを設定
            sess['_user_id'] = self.test_user.id
            sess['_fresh'] = True
        
        response = self.client.get('/dashboard')
        self.assertEqual(response.status_code, 200)
    
    def test_api_authentication(self):
        """API認証テスト"""
        # API Key認証
        response = self.client.get('/api/profile', headers={
            'X-API-Key': 'admin-api-key-123'
        })
        self.assertEqual(response.status_code, 200)
        
        # 無効なAPI Key
        response = self.client.get('/api/profile', headers={
            'X-API-Key': 'invalid-key'
        })
        self.assertEqual(response.status_code, 401)
    
    def test_with_logged_in_user(self):
        """ログイン済みユーザーでのテスト"""
        with self.client.user(self.test_user):
            # このリクエストはログイン済み状態で実行される
            response = self.client.get('/dashboard')
            self.assertEqual(response.status_code, 200)
            self.assertIn('Test User', response.get_data(as_text=True))
    
    def test_remember_me(self):
        """Remember Me機能のテスト"""
        response = self.client.post('/login', data={
            'email': '[email protected]',
            'password': 'testpass',
            'remember': 'on'
        })
        
        # remember_tokenクッキーが設定されているかチェック
        cookies = {cookie.name: cookie.value for cookie in self.client.cookie_jar}
        self.assertIn('remember_token', cookies)
    
    def test_invalid_login(self):
        """無効なログインのテスト"""
        response = self.client.post('/login', data={
            'email': '[email protected]',
            'password': 'wrongpass'
        })
        
        self.assertEqual(response.status_code, 200)
        self.assertIn('正しくありません', response.get_data(as_text=True))

if __name__ == '__main__':
    unittest.main()