【Python】OTP（ワンタイムパスコード）を実装する

## はじめに

今回は`pyotp`と`qrcode`を使ってOTP（ワンタイムパスコード）を使えるようにしていきます。

## 実行環境

- Python 3.11
- Windows 10

## 使用ライブラリ

- pyotp
- qrcode

## ライブラリのインストール

今回のプログラムでは`pyotp`と`qrcode`の2つの外部ライブラリを使用します。以下のコマンドでインストールしましょう。どちらのコマンドを使っても大丈夫です。

```:ライブラリのインストール
pip install pyotp
pip install qrcode

又は

pip install pyotp qrcode
```

## 実装

以下のコードを先程作成した.pyファイルにコピペしてください。
実行するとターミナルにシークレットキーが表示されます。
表示されたシークレットキーをOTPアプリに入力するか、生成される`otp_ar.png`のQRコードを読み取って登録！
OTPアプリに表示されたワンタイムパスコードを入力すると検証結果が表示されます。

:::note warn
プログラムをもう一度実行すると新しいシークレットキーが生成され、前回生成したシークレットキーは無効になります。
:::

:::note info
任意でコード内の`example@domain.com`と`YourAppName`は変更してください。
:::

```python:コード全文
import pyotp
import qrcode

# ユーザーごとのシークレットキーを生成
secret = pyotp.random_base32()
print(f"Your secret key: {secret}")

# ワンタイムパスコードの生成
totp = pyotp.TOTP(secret)

# QRコード生成用のURL
url = totp.provisioning_uri(name="example@domain.com", issuer_name="YourAppName")
print(f"Provisioning URL: {url}")

# QRコードを生成して保存
qrcode.make(url).save("otp_qr.png")
print("QR code saved as otp_qr.png")

# ユーザー入力と検証
user_input = input("Enter the OTP: ")

if totp.verify(user_input):
    print("Login successful!")
else:
    print("Invalid OTP. Please try again.")

```

## おわりに

今回はOTPを実装してみました。
こちらの記事がOTPを実装する際の参考になれば嬉しいです。

## おまけ

実際に実装するとなると現在のコードでは毎回シークレットキーが生成されてしまうので、実務的には使いにくいです。
例えば、シークレットキーをtxtやjsonに保存し、それを再利用するのをおすすめします。
適当に作ってみたので置いときます。

```python:おまけ 
import pyotp
import qrcode
import os
import tkinter as tk
from PIL import Image, ImageTk
from tkinter import messagebox
import tkinter.simpledialog as simpledialog
import json

security_data_path = "security.json"

if not os.path.exists(security_data_path):
    data = {
        "OTP_secret_key": None
    }

    # JSONファイルへの書き込み
    with open(security_data_path, 'w', encoding='utf-8') as file:
        json.dump(data, file, ensure_ascii=False, indent=4)

# JSONファイルの読み込み
with open(security_data_path, 'r', encoding='utf-8') as file:
    security_data = json.load(file)

# シークレットキーの保存先
SECRET_KEY_FILE = "OTP_secret_key.txt"

if not security_data['OTP_secret_key'] == None:
    # シークレットキーを取得
    secret = security_data['OTP_secret_key']
else :
    secret = pyotp.random_base32()


# ワンタイムパスコードの生成
totp = pyotp.TOTP(secret)

# QRコード生成用のURL
url = totp.provisioning_uri(name="最強セキュリティ", issuer_name="mikan")

# QRコードを生成して保存（初回のみ）
if not security_data['OTP_secret_key'] == secret:
    qrcode.make(url).save("otp_qr.png")
    
    path = "otp_qr.png"
    
    # ボタンが押されたときにループを終了するフラグ
    loop_running = True

    def end_loop():
        # os.remove(path) QRコードを削除
        global loop_running
        loop_running = False
        root.destroy()  # ウィンドウを閉じる

    # メインウィンドウの作成
    root = tk.Tk()
    root.title("QR Code Display with Text")
    root.geometry("500x500")  # ウィンドウサイズ
    
    # ×ボタンを押されたときの処理を設定
    root.protocol("WM_DELETE_WINDOW", end_loop)
    
    # 画像のリサイズと読み込み
    image = Image.open(path)
    image = image.resize((300, 300))  # 幅300px、高さ300pxにリサイズ
    tk_image = ImageTk.PhotoImage(image)

    # Canvasの作成（画像とテキストを配置）
    canvas = tk.Canvas(root, width=500, height=400)
    canvas.pack()

    # テキストを画像の上部に描画（Canvas上部に表示）
    canvas.create_text(250, 30, text="以下のQRコードでOTPアプリに\n設定してください。", fill="black", font=("Arial", 20))

    # 画像をCanvasに描画（テキストの下に画像を配置）
    canvas.create_image(250, 225, image=tk_image)  # テキストから少し下に配置

    # OKボタンの作成
    ok_button = tk.Button(root, text="登録が終わった", command=end_loop)
    ok_button.pack(pady=20)

    # メインループ
    while loop_running:
        root.update()

# 新しく生成したシークレットキーをJSONに保存
if security_data['OTP_secret_key'] == None:
    security_data['OTP_secret_key'] = secret
    with open(security_data_path, 'w', encoding='utf-8') as file:
        json.dump(security_data, file, ensure_ascii=False, indent=4)

# ユーザー入力と検証
user_input = simpledialog.askstring('OTP入力', 'ワンタイムパスコードを入力してください：')

if totp.verify(user_input):
    messagebox.showinfo("成功", "OTP認証に成功しました。")
else:
    messagebox.showinfo("失敗", "OTP認証に失敗しました。")
    os._exit(0)
```

このコードでは初回実行時にシークレットキーが生成され`security.json`に保存されます。その後生成されたQRコードを表示しています。
二回目以降からは`security.json`に保存されたシークレットキーを使用し検証だけを行います。
それとtkinter使って少し使いやすくしました。

:::note warn
同じシークレットキーを使用する場合には`security.json`を削除しないでください。
:::