全国オンライン対応受付 平日 9:00-18:00
お問い合わせ

開発記録 / 1cto.jp制作記 / Vol.23 1cto.jp制作記 Vol.2

記事 05

パスワードリセット機能を30分で作った話

後回しにしていた機能だ。メール送信コードが既に存在していた。それだけが理由だ。既存コードの再利用が設計の恩恵になった瞬間だった。

2026-06-07 公開

30分で動いた。

後回しにしていた機能だ。メール送信コードが既に存在していた。それだけが理由だ。既存コードの再利用が設計の恩恵になった瞬間だった。


必要なものを整理する

パスワードリセットのフローはどのサービスでも同じだ。

  1. ユーザーがメールアドレスを入力して「リセットメールを送る」を押す
  2. そのメールアドレスに、リセット用のURLが届く
  3. URLを開くとパスワード変更フォームが表示される
  4. 新しいパスワードを入力して完了

実装に必要なものは「ランダムトークン生成」「メール送信」「トークン検証」の3つだ。それぞれPythonの標準ライブラリとFastAPIで実装できる。


ランダムトークンの生成

Pythonのsecretsモジュールを使うとURLセーフなランダムトークンを生成できる。

import secrets

token = secrets.token_urlsafe(32)
# → 例: "Dg2pQ8jKm3N4-xL0vRwE_fCbT1sIaYk5"

このトークンをDBに保存する。usersテーブルに2カラムを追加した。

ALTER TABLE users
ADD COLUMN reset_token VARCHAR(64),
ADD COLUMN reset_token_expires_at TIMESTAMP;

有効期限は24時間にした。

from datetime import datetime, timedelta, timezone

user.reset_token = token
user.reset_token_expires_at = datetime.now(timezone.utc) + timedelta(hours=24)
db.commit()

GmailのSMTPでメールを送る

リセットURLはhttps://1cto.jp/auth/reset-password?token={token}という形式だ。

メール送信にはGmailのSMTPを使う。1cto.jpのお問い合わせフォームのメール通知が同じ仕組みで実装済みだったため、送信部分はコピーして2分で終わった。

import smtplib
from email.mime.text import MIMEText

def send_reset_email(to_email: str, token: str):
    reset_url = f"{SITE_URL}/auth/reset-password?token={token}"
    body = f"""
1cto.jpのパスワードリセットリクエストを受け付けました。

以下のURLからパスワードを再設定してください(24時間有効):
{reset_url}

このメールに心当たりがない場合は、無視してください。
"""
    msg = MIMEText(body, 'plain', 'utf-8')
    msg['Subject'] = '【1cto.jp】パスワードリセット'
    msg['From'] = GMAIL_ADDRESS
    msg['To'] = to_email

    with smtplib.SMTP('smtp.gmail.com', 587) as server:
        server.starttls()
        server.login(GMAIL_ADDRESS, GMAIL_APP_PASSWORD)
        server.sendmail(GMAIL_ADDRESS, to_email, msg.as_string())

EC2のSMTP制限

一つ詰まった点がある。EC2ではSMTPの25番ポートがデフォルトでブロックされている。AWSがスパム対策として設けている制限だ。

しかしGmailのSMTPは587番ポート(STARTTLS)を使う。587番はブロックされていないため、そのまま使える。

smtplib.SMTPでGmailを使うとき、Gmailアカウントのパスワードは使えない。Googleの2段階認証が有効な場合、アプリパスワードという専用のパスワードを発行する必要がある。

Google アカウント → セキュリティ → 2段階認証プロセス → アプリパスワード、から発行できる。16文字のランダムな英字が表示される。スペースを除いた16文字を.envGMAIL_APP_PASSWORDとして設定する。


トークン検証とパスワード更新

リセットURLを開いたユーザーが新しいパスワードを入力して送信すると、バックエンドでトークンを検証する。

@router.post('/auth/reset-password')
def reset_password(token: str, new_password: str, db: Session = Depends(get_db)):
    now = datetime.now(timezone.utc)
    user = db.query(User).filter(
        User.reset_token == token,
        User.reset_token_expires_at > now
    ).first()

    if not user:
        raise HTTPException(status_code=400, detail='無効または期限切れのトークンです')

    user.password_hash = bcrypt.hashpw(new_password.encode(), bcrypt.gensalt()).decode()
    user.reset_token = None
    user.reset_token_expires_at = None
    db.commit()

    return {'message': 'パスワードを更新しました'}

トークンを使い終わったらNULLにする。同じURLを2回開いても2回目はトークンが存在しないため、エラーになる。


セキュリティ上の注意点

リセットメールの送信時、「そのメールアドレスのアカウントは存在しません」という詳細なエラーを返さない。「入力されたメールアドレスにリセットリンクを送りました」と必ず返す。

アカウントが存在するかどうかをエラーで確認できると、アカウント有無の列挙攻撃(enumeration attack)が可能になるためだ。存在しないメールアドレスには実際にはメールを送らないが、レスポンスは同じにする。

user = db.query(User).filter(User.email == email).first()
if user:
    token = generate_reset_token(user, db)
    send_reset_email(user.email, token)

return {'message': '入力されたメールアドレスにリセットリンクを送りました'}

30分で動いた理由は「お問い合わせフォームのGmailメール送信コードが既に存在していた」ことに尽きる。一度動く実装があれば、それを流用するだけで別機能の送信部分が完成する。

後回しにした機能が、積み上げてきたコードの恩恵を受けて完成した。


*シンプルシステム株式会社 代表 伊藤勝彦*