LINEミニアプリと連携!Flaskで外部向けAPIを作ってNginxリバースプロキシで安全に公開する

未分類

LINEミニアプリ(LIFF)からスマートホームを操作したい、外部から自宅サーバーのAPIを安全に叩きたい——そんな用途には、PythonのWebフレームワーク「Flask」で自作APIサーバーを構築し、NginxのリバースプロキシでHTTPS公開するアーキテクチャが最適です。
本記事では、FlaskのBlueprint機能を使って外部向けAPI・内部向けAPI・Webhookを整理し、LINE IDトークン認証とレートリミットを組み込んで外部公開する実装手順を解説します。systemdによるプロセス管理も設定し、サーバー再起動時に自動起動する構成まで仕上げます。

なぜこの機能を作ったのか?

LINEミニアプリ(LIFF)でGoogle Homeに発話させたり、外出先からスマートホームの状態を確認したりするには、インターネット経由で呼び出せるAPIエンドポイントが必要です。
しかし「APIを外部公開する=誰でもアクセスできる」では困ります。家族のLINEアカウントからの呼び出しだけを許可し、それ以外はすべて弾く仕組みが必要です。
LINE IDトークン(JWT)を使った認証を自前で実装することで、LINEアカウントを持つ特定ユーザーだけが使えるプライベートAPIを無料・自前で構築できます。NginxがHTTPS暗号化とパスルーティングを担当し、FlaskはビジネスロジックとAPI認証に専念するシンプルな役割分担です。

外出先からLINEミニアプリでGoogle Homeに喋らせたり、スマートホームを操作できるAPIを作りたい!

実現したいこと

  • LINEミニアプリ(LIFF)から https://xxxx.tplinkdns.com/external/api/... でAPIを呼び出せる
  • LINE IDトークン認証で、許可した家族のLINEアカウントからのリクエストだけを受け付ける
  • 外部向けAPI・内部向けAPI・WebhookをFlaskのBlueprintで分離管理する
  • Flask-Limiterでレートリミットを設け、過剰なリクエストを防ぐ
  • systemdサービスで自動起動・プロセス監視を行う
  • NginxリバースプロキシでHTTPS暗号化しながらFlaskに転送する

この記事でわかること

  • FlaskのBlueprint機能でAPIエンドポイントを役割ごとに分離する方法
  • LINE IDトークン(JWT)をFlaskで検証する認証デコレーターの実装方法
  • Flask-LimiterでAPIのレートリミットを設定する方法
  • systemdサービスファイルでFlaskアプリを自動起動・再起動させる方法
  • Nginxの proxy_pass でURLパスを変換しながらFlaskに転送する方法

必要な準備と用意するもの

ハードウェア

ハードウェア
  • 自宅サーバー(本記事では MINISFORUM NUCBox G3 Plus / Ubuntu Server 24.04)

ソフトウェア/サービス

ソフトウェア/サービス
  • Nginx(インストール済み・稼働中)
    • 参考記事:「NginxでHTTPSリバースプロキシを構築する」
  • Let’s Encrypt SSL証明書(取得済み)
  • Python 3.12 + venv(仮想環境)
  • Flask 3.1.2
  • Flask-Limiter 4.1.1
  • LINEアカウント + LINE Developersのチャンネル(LIFFアプリ)
    • LINE Channel ID が必要

完成イメージ

設定完了後のAPIエンドポイントと利用方法のイメージです。

外部URL(Nginx)Flaskの内部URL認証用途
/external/api/ping/api/pingなし疎通確認
/external/api/check_user/api/check_userLINE IDトークン認証テスト
/external/api/speak_googlehome/api/speak_googlehomeLINE IDトークンGoogle Home発話
/webhooks/.../webhooks/...各サービス固有外部Webhook受信
/internal/.../internal/...LAN内IPのみ許可LAN内専用API

NginxのURL(/external/)とFlask内部のBlueprintパス(/api/)は意図的に異なる名前にしています。Nginxのパス名を変えるだけで外部公開URLを変更でき、Flaskのコードは変更不要になります。

システムの仕組み

リクエストの流れ

外部からAPIを呼び出したとき、リクエストは以下の経路で処理されます。

LINEミニアプリ(ブラウザ) → https://xxxx.tplinkdns.com/external/api/speak_googlehome
 ↓ Nginx(443番ポート、SSL終端)
 ↓ proxy_pass http://127.0.0.1:8000/api//external//api/ にパス変換)
 ↓ Flask(8000番ポート)
 ↓ Blueprint「api」→ dispatcher → LINE IDトークン検証 → エンドポイント関数実行
 ↓ Google Home に発話リクエスト送信

ファイル・ディレクトリ構成


smarthome/
└── interfaces/
    └── inbound/
        ├── http_server.py          # Flaskアプリ本体(エントリーポイント)
        ├── api/                    # 外部向けAPI Blueprint
        │   ├── __init__.py         # Blueprint定義
        │   ├── dispatcher.py       # 認証デコレーター・APIエンドポイント
        │   └── routes.py           # シンプルなルート(pingなど)
        ├── internal/               # LAN内向けAPI Blueprint
        │   ├── __init__.py
        │   └── dispatcher.py
        └── webhooks/               # Webhook受信 Blueprint
            ├── __init__.py
            └── routes.py

LINE IDトークン認証の仕組み

LINEミニアプリはユーザーがログインすると LINE IDトークン(JWT)を発行します。このトークンをAPIリクエストの Authorization: Bearer <token> ヘッダに乗せて送信し、FlaskはLINEのAPIエンドポイント(https://api.line.me/oauth2/v2.1/verify)で検証します。

ステップ処理内容
① LINEアプリでログインLINEがIDトークン(JWT)を発行
② APIリクエスト送信Authorization: Bearer <IDトークン> ヘッダ付きでリクエスト
③ Flask受信デコレーター @require_line_auth がトークンを抽出
④ LINE APIで検証LINEの検証APIにトークンを送って正当性を確認
⑤ ユーザー確認LINEのユーザーID(sub)が許可リストにあるか確認
⑥ 処理実行 or 拒否許可ユーザーならエンドポイント関数へ。それ以外は401/403を返す

実装のポイント

BlueprintでAPIの役割を明確に分離する
Flaskの Blueprint 機能を使うと、外部向けAPI・LAN内向けAPI・Webhookを別ファイルに分離できます。それぞれに個別の url_prefix を設定でき、コードが整理されてメンテナンスしやすくなります。

IDトークン検証結果をキャッシュしてLINE APIの呼び出し回数を削減する
LINE IDトークンの有効期限(exp)まではキャッシュに保持し、同じトークンで繰り返しリクエストが来ても外部API呼び出しを1回に抑えます。これによりレスポンスの高速化と外部API依存度の低減を実現しています。

Nginxのパス変換でFlask内部のURL設計を自由にできる
Nginxの location /external/proxy_pass http://127.0.0.1:8000/api/; と設定することで、外部URL(/external/)とFlask内部のパス(/api/)を別々に管理できます。外部公開URLを変えたくなってもFlaskのコードは一切変更不要です。

Flask組み込みサーバーは本番環境に使わない
app.run() で起動するFlask組み込みサーバーはシングルスレッドで、本番環境には非推奨です。本記事の構成ではNginxがリバースプロキシとしてリクエストを受け取り、Flaskへの同時リクエストは実際にはNginxがバッファリングします。さらに並列処理が必要な場合はGunicornやuvicornの導入を検討してください。

AuthorizationヘッダーをNginxで明示的に転送する
Nginxのデフォルト設定では Authorization ヘッダーが下流に転送されない場合があります。proxy_set_header Authorization $http_authorization; を明示的に追加することで、LINE IDトークンをFlaskが受け取れるようになります。

事前準備

① Python仮想環境のセットアップ

システム全体のPythonに影響を与えないよう、仮想環境(venv)を使います。


# プロジェクトフォルダに移動
cd ~/Projects

# 仮想環境を作成
python3 -m venv venv

# 仮想環境を有効化
source venv/bin/activate

② Flaskと必要なパッケージのインストール


pip install flask flask-limiter requests

インストール後にバージョンを確認します。


pip show flask flask-limiter
# Name: Flask
# Version: 3.1.2
# Name: Flask-Limiter
# Version: 4.1.1

③ LINE DevelopersでChannel IDを確認する

LINE IDトークンの検証には LINE Channel ID が必要です。LINE Developersコンソール(https://developers.line.biz/)でLIFFを設定したチャンネルの「Basic settings」ページにある「Channel ID」を控えておきます。

実装方法

① Blueprint定義(api/__init__.py)

まず外部向けAPIのBlueprintを定義します。


from flask import Blueprint

# 外部向けAPIをまとめるBlueprint
api_bp = Blueprint(
    "api",          # Blueprint識別名(アプリ内で一意)
    __name__,       # このBlueprintが属するモジュール名
    url_prefix="/api"
)

# dispatcherとroutesを読み込むことでエンドポイントをBlueprintに登録する
from . import dispatcher
from . import routes

__all__ = ["api_bp"]

② 認証デコレーターとAPIエンドポイント(api/dispatcher.py)

LINE IDトークン認証の核心部分です。認証デコレーター @require_line_auth を作成し、保護したいエンドポイントに付与します。


from functools import wraps
from flask import g, request, abort, jsonify
from flask_limiter import Limiter
from flask_limiter.util import get_remote_address
import time
import requests

from . import api_bp

# ----------------------------------------
# Flask-Limiter の初期設定
# ----------------------------------------
limiter = Limiter(
    key_func=get_remote_address,
    default_limits=[]   # デフォルトは制限なし(エンドポイントごとに個別設定)
)

# ----------------------------------------
# 設定
# ----------------------------------------
LINE_CHANNEL_ID = "xxxxxxxxxx"   # LINE DevelopersのChannel ID

# 許可するLINEユーザーのIDリスト(各ユーザーのsub値)
LINE_USER_MAP = {
    "Uxxxxxxxxxxxxxxxxxxxx": {"name": "user1"},
    "Uyyyyyyyyyyyyyyyyyyyyyy": {"name": "user2"},
}

# ----------------------------------------
# LINE IDトークン検証(LINE APIに問い合わせ)
# ----------------------------------------
def verify_line_id_token(id_token):
    url = "https://api.line.me/oauth2/v2.1/verify"
    data = {
        "id_token": id_token,
        "client_id": LINE_CHANNEL_ID
    }
    headers = {"Content-Type": "application/x-www-form-urlencoded"}
    r = requests.post(url, data=data, headers=headers, timeout=3)

    if r.status_code != 200:
        return None
    return r.json()

# ----------------------------------------
# IDトークン検証キャッシュ(有効期限内は再検証しない)
# ----------------------------------------
token_cache = {}

def verify_line_id_token_cached(id_token):
    now = time.time()
    # 期限切れキャッシュの削除
    for token in list(token_cache.keys()):
        if token_cache[token]["exp"] <= now:
            del token_cache[token]
    # キャッシュヒットなら返す
    if id_token in token_cache:
        cached = token_cache[id_token]
        if cached["exp"] > now:
            return cached["data"]
    # キャッシュミス:LINE APIで検証してキャッシュに保存
    user_info = verify_line_id_token(id_token)
    if user_info:
        token_cache[id_token] = {
            "data": user_info,
            "exp": user_info.get("exp", 0)
        }
    return user_info

# ----------------------------------------
# 認証デコレーター
# ----------------------------------------
def require_line_auth(f):
    @wraps(f)
    def wrapper(*args, **kwargs):
        auth = request.headers.get("Authorization", "")
        if not auth.startswith("Bearer "):
            return jsonify({"status": "error", "reason": "missing token"}), 401

        id_token = auth.replace("Bearer ", "")
        user_info = verify_line_id_token_cached(id_token)

        if not user_info:
            return jsonify({"status": "error", "reason": "invalid token"}), 401

        user_id = user_info.get("sub")
        if user_id not in LINE_USER_MAP:
            return jsonify({"status": "error", "reason": "forbidden"}), 403

        # エンドポイント関数内で g.user からユーザー情報を参照できる
        g.user = {
            "line": user_info,
            "config": LINE_USER_MAP[user_id]
        }
        return f(*args, **kwargs)
    return wrapper

# ----------------------------------------
# APIエンドポイント
# ----------------------------------------

@api_bp.route('/api/check_user', methods=['GET'])
@require_line_auth
def check_user():
    # 認証テスト用エンドポイント
    return jsonify({"status": "ok"}), 200


@api_bp.route('/api/speak_googlehome', methods=['POST'])
@require_line_auth
@limiter.limit("10 per minute")   # 1分間に10回まで
def speak_googlehome():
    # Google Homeに指定メッセージを発話させる
    try:
        data = request.get_json()
        message = data.get("message")

        if not message or len(message) > 255:
            return jsonify({"status": "error", "reason": "invalid message"}), 400

        # Google Homeへの発話処理(別モジュールで実装)
        # google_notify.speak(message)

        return jsonify({"status": "ok"}), 200

    except Exception:
        return jsonify({"status": "error"}), 500

③ Flaskアプリ本体(http_server.py)

各Blueprintをまとめて登録するエントリーポイントです。


from flask import Flask

from interfaces.inbound.api import api_bp
from interfaces.inbound.api.dispatcher import limiter
from interfaces.inbound.internal import internal_bp
from interfaces.inbound.webhooks import webhooks_bp

app = Flask(__name__)
limiter.init_app(app)   # Flask-LimiterをFlaskアプリに紐付ける

# Blueprintを登録(url_prefixで各Blueprintのベースパスを設定)
app.register_blueprint(api_bp,       url_prefix="/api")
app.register_blueprint(internal_bp,  url_prefix="/internal")
app.register_blueprint(webhooks_bp,  url_prefix="/webhooks")

if __name__ == "__main__":
    app.run(host="0.0.0.0", port=8000)

④ systemdサービスの設定

Flaskアプリをサーバー起動時に自動起動させるsystemdサービスファイルを作成します。


sudo nano /etc/systemd/system/smh-http-server.service

[Unit]
Description=Smarthome HTTP Server (Flask)
After=nginx.service
Wants=nginx.service

[Service]
# 仮想環境のPythonでhttp_server.pyを起動する
ExecStart=/home/user/Projects/venv/bin/python /home/user/Projects/smarthome/interfaces/inbound/http_server.py
Restart=always          # 異常終了時に自動再起動
RestartSec=60           # 再起動までの待機時間(秒)
Type=simple
User=user               # 実行ユーザー(rootは使わない)

[Install]
WantedBy=multi-user.target

サービスを有効化して起動します。


# systemdにサービスファイルを認識させる
sudo systemctl daemon-reload

# OS起動時に自動起動する設定 & 今すぐ起動
sudo systemctl enable --now smh-http-server

# 起動状態を確認
sudo systemctl status smh-http-server

⑤ Nginx設定にAPIのロケーションブロックを追加する

既存のNginx HTTPS設定(443番ポートのサーバーブロック)に、外部向けAPIとWebhookのロケーションブロックを追加します。


server {
    listen 443 ssl;
    server_name xxxx.tplinkdns.com;

    # SSL設定(省略)...

    # -----------------------------------------
    # /external/ : 外部向けFlask API
    # -----------------------------------------
    location /external/ {
        proxy_pass http://127.0.0.1:8000/api/;          # /external/ → /api/ にパス変換
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
        proxy_cache_bypass $http_upgrade;

        # LINEのBearerトークンをFlaskに転送する(これがないと認証が通らない)
        proxy_set_header Authorization $http_authorization;

        # アクセスログを外部API用ファイルに分離
        access_log /srv/nginx/log/api.log detailed;
    }

    # -----------------------------------------
    # /webhooks/ : 外部Webhook受信
    # -----------------------------------------
    location /webhooks/ {
        proxy_pass http://127.0.0.1:8000/webhooks/;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
        proxy_cache_bypass $http_upgrade;
    }
}

LAN内向けサーバーブロック(80番ポート)にはIPアドレス制限付きの内部APIを追加します。


server {
    listen 80;
    server_name 192.168.68.89;

    # -----------------------------------------
    # /internal/ : LAN内専用API(IPアドレス制限あり)
    # -----------------------------------------
    location /internal/ {
        proxy_pass http://127.0.0.1:8000/internal/;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

        allow 192.168.68.0/24;   # LAN内のみ許可
        deny all;

        access_log /srv/nginx/log/api_internal.log detailed;
    }
}

⑥ Nginx設定の確認とリロード


# 構文チェック
sudo nginx -t

# 問題なければリロード
sudo systemctl reload nginx

コードの解説

Blueprint の url_prefix と Nginx の proxy_pass の関係

FlaskのBlueprintは url_prefix="/api" で登録されているため、エンドポイント @api_bp.route('/api/speak_googlehome') の最終URLは /api/api/speak_googlehome になります。

url_prefix の二重定義に注意
http_server.pyapp.register_blueprint(api_bp, url_prefix="/api") と、__init__.pyBlueprint(..., url_prefix="/api") の両方でプレフィックスを定義すると二重になります。どちらか一方にまとめるか、__init__.py 側は url_prefix を省略して register_blueprint 側だけで指定するのがシンプルです。

Nginx の proxy_pass パス変換の詳細

ブラウザが送るURLNginx設定Flaskが受け取るURL
/external/api/pinglocation /external/ { proxy_pass http://127.0.0.1:8000/api/; }/api/ping
/webhooks/pinglocation /webhooks/ { proxy_pass http://127.0.0.1:8000/webhooks/; }/webhooks/ping

外部向けAPIは /external//api/ とパスが変換されます。Webhookは /webhooks/ のまま変換されません。このようにNginxで外部URLとFlask内部URLを分離することで、Flask側のコードを変えずに外部公開パスを柔軟に変更できます。

Flask-Limiter によるレートリミット


@api_bp.route('/api/speak_googlehome', methods=['POST'])
@require_line_auth
@limiter.limit("10 per minute")   # 同じIPから1分間に10回まで
def speak_googlehome():
    ...

@limiter.limit("10 per minute") を付けると、同一IPアドレスからのリクエストを1分間に10回に制限します。制限を超えると自動的に 429 Too Many Requests を返します。Google Home発話APIのように外部に副作用のある処理に設定しておくと、誤操作や悪用によるスパムを防げます。

動作確認

① Flaskサービスの起動確認


sudo systemctl status smh-http-server
# Active: active (running) と表示されればOK

# ポート8000でListenしているか確認
ss -tlnp | grep 8000
# LISTEN  0  128  0.0.0.0:8000  ...

② pingエンドポイントで疎通確認


# サーバー内からFlaskに直接アクセス(認証なし)
curl http://127.0.0.1:8000/api/ping
# {"status": "ok"}

# Nginx経由でアクセス(外部URL)
curl https://xxxx.tplinkdns.com/external/api/ping
# {"status": "ok"}

③ LINE IDトークン認証の確認


# 認証なしでアクセス → 401 Unauthorized
curl https://xxxx.tplinkdns.com/external/api/check_user
# {"reason":"missing token","status":"error"}

# 有効なLINE IDトークンを付けてアクセス → 200 OK
curl -H "Authorization: Bearer <LINEのIDトークン>" \
  https://xxxx.tplinkdns.com/external/api/check_user
# {"status":"ok"}

④ トラブルシューティング

Nginx経由でアクセスすると401になる(直接8000番では動く)
Authorization ヘッダーがNginxで転送されていない可能性があります。Nginx設定に proxy_set_header Authorization $http_authorization; が追加されているか確認します。

502 Bad Gateway が返る
Flaskが起動していません。sudo systemctl status smh-http-server で状態を確認し、エラーログは journalctl -u smh-http-server -n 50 で確認します。

LINE IDトークン検証が常に失敗する
LINE_CHANNEL_ID が正しいか確認します。LIFFアプリを作成したチャンネルのChannel IDと一致している必要があります。また、トークンの有効期限(通常数分〜数時間)が切れていないか確認します。

レートリミットエラー(429 Too Many Requests)が頻発する
Nginxの proxy_set_header X-Real-IP $remote_addr; が設定されていることを確認します。これがないとFlask-LimiterがNginxのループバックIP(127.0.0.1)をクライアントIPと誤認し、全リクエストが同一IPとしてカウントされます。

まとめ

本記事では、FlaskのBlueprint構成でスマートホーム向けAPIを構築し、Nginxリバースプロキシ経由でHTTPS外部公開する方法を解説しました。

  • FlaskのBlueprintで外部API・内部API・Webhookを役割ごとに分離した
  • LINE IDトークンをLINE APIで検証する認証デコレーター @require_line_auth を実装し、特定LINEユーザーのみに限定した
  • 検証結果をキャッシュして外部API呼び出し回数を削減した
  • Flask-Limiterでエンドポイントごとにレートリミットを設定した
  • systemdサービスでサーバー再起動時の自動起動・異常終了時の自動再起動を設定した
  • Nginxの proxy_pass でURLパスを変換し、外部公開パスとFlask内部パスを分離した
  • proxy_set_header Authorization $http_authorization; でBearerトークンをFlaskに転送した

このAPIサーバーを基盤として、Google Home発話・SwitchBot操作・センサーデータ取得など、スマートホームのさまざまな機能をLINEミニアプリから操作できるようになります。

タイトルとURLをコピーしました