30分で動いた。
後回しにしていた機能だ。メール送信コードが既に存在していた。それだけが理由だ。既存コードの再利用が設計の恩恵になった瞬間だった。
必要なものを整理する
パスワードリセットのフローはどのサービスでも同じだ。
- ユーザーがメールアドレスを入力して「リセットメールを送る」を押す
- そのメールアドレスに、リセット用のURLが届く
- URLを開くとパスワード変更フォームが表示される
- 新しいパスワードを入力して完了
実装に必要なものは「ランダムトークン生成」「メール送信」「トークン検証」の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文字を.envにGMAIL_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メール送信コードが既に存在していた」ことに尽きる。一度動く実装があれば、それを流用するだけで別機能の送信部分が完成する。
後回しにした機能が、積み上げてきたコードの恩恵を受けて完成した。
*シンプルシステム株式会社 代表 伊藤勝彦*