🔑

パスワード再設定APIをGoで実装する

2024/04/17に公開

はじめに

こんにちは、最近社会人になりました、かみけんです。
最近は新卒で入社した企業でできた友達らと一緒にWeb開発しており、その時に必要になったパスワード再設定機能の実装方法を記事にします。(初めてGoを触った開発物になるのでお手柔らかに・・・)

このWebサービス一応実装は一通り終わったのですが、著作権に引っかかってリリースできませんでした笑
これからブログにて昇華しようかと。

で、今回投稿するパスワード再設定APIは、ユーザー情報を更新するひとつのAPIなのですが、他の名前やメールアドレスと違ってパスワードの更新だけはセキュリティ面を気にしなきゃいけないので少しだけ実装の仕方が違います。そこの手順みたいなのまとめれればと思います。

最近テックブログ始めたばっかでモチベ高いですが、いいねがあるとすごい励みになるのでいいねお願いします!!

デモ

実装環境

まず、実装環境。以下を使用します。他にも便利機能として、AirやGoplsを使用しています(今回始めてGopls使用しましたが、正直革命)。

  • 使用言語: Go
  • Webフレームワーク: Gin
  • ORMライブラリ: Gorm
  • データベース管理: MySQL

実装のステップ

実装には主に二つのAPIを設計しようかなと思いました。簡単な概要はこんな感じです。
まず、パスワード再設定リクエストのエンドポイントで、期限付きのトークンを発行しこれ用のデータベースにトークンとハッシュ化されたパスワードを格納し、ユーザーに対してトークンを含むエンドポイントをURLで送信します。
そのあと、リクエストボディにメールに付与されたトークンと新しいパスワードを含むパスワードリセット用のAPIを叩きます。これによってメールから飛ばないとパスワードが変更できないようにすることができます。このリクエストボディはJsonによって処理しています。このトークンは定期実行処理によって、期限が切れるとデータベースから削除されるように設定します。
以下に、エンドポイントの設計を書きました!

パスワード再設定リクエストのエンドポイント

  • エンドポイント: /edit/password_reset_request
  • メソッド: POST
  • 目的: ユーザーがパスワード再設定のリクエストを行うためのエンドポイント
  • リクエストボディ: 普通ならメアドが入るかと思いますが、今回は認証を行なった後を想定しているのでユーザー情報からこちらを取得します。
  • レスポンス:
    • 成功: 状態コード200とともに、パスワード再設定メールが送信されたことを示すメッセージ。
    • 失敗: エラーメッセージと状態コード(例: 400 バッドリクエスト、500サーバーエラー)

パスワードリセットのエンドポイント

  • エンドポイント: edit/password_reset
  • メソッド: POST
  • 目的: ユーザーが新しいパスワードを設定するためのエンドポイント
  • リクエストボディ:
    • token: パスワード再設定用のトークン
    • password: 新しいパスワード
  • レスポンス:
    • 成功: 状態コード200とともに、パスワードが正常にリセットされたことを示すメッセージ
    • 失敗: エラーメッセージと状態コード

実装

実際の実装を、コード見せながら説明しようと思います。
以下にコードの解説とコードを乗せました! 参考になれば幸いです!

データベースと構造体に関わるコード

  • 構造体PasswordResetToken: この構造体はデータベースにオートマイグレートされているpassword_reset_tokensテーブルのモデルを表しています。ここには、トークン文字列、ユーザー名、トークンの有効期限を格納しています。ご存じと方が大半だと思いますが、gorm.Model を入れることによって、IDCreatedAtUpdatedAtDeletedAtフィールドが含まれます。Go便利すぎ。
  • モジュールCreatePasswordToken: メールアドレスに基づき新しいパスワードリセットトークンを生成し、データベースに保存します。
  • モジュールPasswordReset: つくられたトークンと新しいパスワードを使用して、ユーザーのパスワードをリセットします。このモジュール内では、トークンが存在するかと期限切れでないかの検証、パスワードのハッシュ化などを考慮して、安全性を担保しました。
  • モジュールGenerateToken: トークンの生成に使われます。32バイトのランダムなデータを生成し、それを16進数の文字列に変換します。
  • モジュールDeleteExpireTokens: 有効期限がきれたトークンを定期的にデータベースから削除するためのモジュールです。
package models

import (
	"crypto/rand"
	"encoding/hex"
	"errors"
	"fmt"
	"time"

	// Importing go-mail package

	"github.com/jinzhu/gorm"
	"golang.org/x/crypto/bcrypt"
)

type PasswordResetToken struct {
	gorm.Model
	Token     string    `gorm:"size:255;not null;unique" json:"token"`
	UserName  string    `gorm:"size:255;not null" json:"username"`
	ExpiresAt time.Time `gorm:"not null" json:"expires_at"`
}

func CreatePasswordResetToken(email string) (*PasswordResetToken, error, int) {

	// メールアドレスが登録されているか確認
	var user User
	result := DB.Where("email = ?", email).First(&user)
	err, status_code := DataBaseError(result.Error)
	if err != nil {
		return nil, err, status_code
	}

	if user.Email == "" {
		err := errors.New("そのメールアドレスは登録されていません。")
		fmt.Println(err)
		return nil, err, 401
	}

	token, err := GenerateToken()
	if err != nil {
		err := errors.New("トークンの生成に失敗しました。")
		return nil, err, 500
	}

	// トークンをDBに保存
	passwordResetToken := PasswordResetToken{
		Token:     token,
		UserName:  user.Username,
		ExpiresAt: time.Now().Add(24 * time.Hour),
	}
	result = DB.Create(&passwordResetToken)
	err, status_code = DataBaseError(result.Error)

	return &passwordResetToken, err, status_code
}

func PasswordReset(token string, password string) (error, int) {

	// トークンの検証 (存在するか,期限切れでないか)
	var passwordResetToken PasswordResetToken

	result := DB.Where("token = ?", token).First(&passwordResetToken)
	err, status_code := DataBaseError(result.Error)
	if err != nil {
		return err, status_code
	}

	if passwordResetToken.Token == "" {
		err := errors.New("そのトークンは存在しません。")
		fmt.Println(err)
		return err, 401
	}

	if passwordResetToken.ExpiresAt.Before(time.Now()) {
		err := errors.New("そのトークンは期限切れです。")
		fmt.Println(err)
		return err, 401
	}

	// ユーザのパスワードを更新
	var user User
	result = DB.Where("username = ?", passwordResetToken.UserName).First(&user)
	err, status_code = DataBaseError(result.Error)
	if err != nil {
		return err, status_code
	}

	if user.Username == "" {
		err := errors.New("ユーザが存在しません。")
		fmt.Println(err)
		return err, 401
	}

	cryptPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
	if err != nil {
		fmt.Println("パスワード暗号化中にエラーが発生しました。:", err)
		return err, 500
	}

	user.Password = string(cryptPassword)
	result = DB.Save(&user)
	err, status_code = DataBaseError(result.Error)
	if err != nil {
		return err, status_code
	}
	result = DB.Delete(&passwordResetToken)
	err, status_code = DataBaseError(result.Error)

	return err, status_code
}

func GenerateToken() (string, error) {
	b := make([]byte, 32)
	_, err := rand.Read(b)
	if err != nil {
		return "", err
	}

	return hex.EncodeToString(b), nil
}

func DeleteExpiredTokens() {
	result := DB.Where("expires_at < ?", time.Now()).Delete(&PasswordResetToken{})
	if result.Error != nil {
		fmt.Println("期限切れのトークンの削除に失敗しました。")
		return
	}
	fmt.Println("期限切れのトークンを削除しました。")
}

API郡のコード

  • モジュールPasswordResetRequestHandler: ユーザーのメアドにパスワードリセットリンクを含むメールを送信します。
  • モジュールPasswordResetHandler: ユーザーがパスワードリセットフォームから送信したトークンと新しいパスワードを用いて、パスワードを更新します。
  • モジュールSendResetPasswordMail: SMTPを使用してパスワードリセットメールを送信します。メールの内容はHTMLテンプレートを作成し、それを呼び出します。
  • モジュールDeleteExpiredTokensTicker: 定期的にDeleteExpiredTokensモジュールを実行するためのタイマーを設定し、期限切れのトークンをクリーンアップします。

今回は、サービス内でメールを送ってパスワードを更新することが確認できればよかったので、テスト用のmailhogを使用します。これは、送信されるべきメアドにはメールが到達せず8025番のローカルサーバー内でメールを確認することができます。この実装は以下の記事を参考にしました:

https://shungoblog.com/docker-go-mailhog-net-smtp/

https://qiita.com/hideji2/items/1919d5759cf42146f919

実際にメールを送信したい場合は、以下を参考にちまたで話題のgo-mailを使用してみるといいかもです。この場合なにかしらのSMTPサーバーを作らなければならないので、少し大変かもです。

https://go-mail.dev/getting-started/introduction/

https://youtu.be/5RfxDF63KFQ?si=fdiwqjb7jpwRz3fu

package controllers

import (
	"bytes"
	"errors"
	"fmt"
	"log"
	"net/smtp"
	"os"
	"strconv"
	"text/template"
	"time"

	"github.com/gin-contrib/sessions"
	"github.com/gin-gonic/gin"
	"recommend-service.com/models"
)

type PasswordResetInput struct {
	Token    string `json:"token" binding:"required"`
	Password string `json:"password" binding:"required"`
}

// @Summary パスワードリセットメール送信
// @Description パスワードリセットメール送信
// @Tags Edit
// @ID password_reset_request
// @Accept json
// @Produce  json
// @Success 200 {string} string "OK"
// @Router /edit/password_reset_request [post]
func PasswordResetRequestHandler(c *gin.Context) {

	// リクエストの構造体を定義
	session := sessions.Default(c)
	userId, _ := session.Get("UserId").(uint)

	// セッションからユーザのメールアドレスを取得
	email, err, status_code := models.ShowEmail(userId)

	ErrorHandler(err, status_code, "DBからメールアドレスを取得する際にエラーが発生しました", c)

	log.Println("Email: " + email)

	// メールアドレスが登録されているか確認し,トークンを生成
	// トークンはDB
	token, err, status_code := models.CreatePasswordResetToken(email)
	if err != nil {
		c.Redirect(301, "/password-reset/request")
		ErrorHandler(err, status_code, "パスワード再設定用トークンの生成に失敗しました", c)
		return
	}

	// メールを送信
	err = SendResetPasswordMail(email, token.Token)
	if err != nil {
		c.Redirect(301, "/password-reset/request")
		ErrorHandler(err, 500, "パスワード再設定用メールの送信に失敗しました", c)
		return
	}

	// メール送信完了画面にリダイレクト
	c.JSON(200, gin.H{
		"message": "メールを送信しました",
	})
}

// @Summary パスワードリセット
// @Description パスワードリセット
// @Tags Edit
// @ID password_reset
// @Accept json
// @Produce  json
// @Success 200 {string} string "OK"
// @Param password-reset body PasswordResetInput true "パスワードリセット"
// @Router /edit/password_reset [post]
func PasswordResetHandler(c *gin.Context) {

	// リクエストの構造体を定義
	var request PasswordResetInput

	if err := c.ShouldBindJSON(&request); err != nil {
		ErrorHandler(err, 400, "リクエスト形式が異なります", c)
		return
	}

	err, status_code := models.PasswordReset(request.Token, request.Password)
	if err != nil {
		c.Redirect(301, "/password-reset/reset")
		ErrorHandler(err, status_code, "DBに新しいパスワードを設定するのに失敗しました", c)
		return
	}

	c.JSON(200, gin.H{
		"message": "パスワードを変更しました",
	})
}

var (
	smtpHost     = os.Getenv("SMTP_HOST")
	smtpUser     = os.Getenv("SMTP_USER") // テスト用メールアドレス
	smtpPass     = os.Getenv("SMTP_PASS") // テスト用メールアドレスのパスワード
	smtpUserName = os.Getenv("SMTP_USER_NAME")
)

func SendResetPasswordMail(to, token string) error {

	// HTMLテンプレートの読み込み
	var body bytes.Buffer
	tmplPath := "./templates/password-reset.html" // テンプレートファイルのパス
	t, err := template.ParseFiles(tmplPath)       // テンプレートファイルの読み込み

	if err != nil {
		err := errors.New("テンプレートの読み込みに失敗しました.: " + err.Error())
		return err
	}

	data := struct {
		ResetURL string
	}{
		ResetURL: "http://localhost:3000/reset-password/" + token,
	}

	err = t.Execute(&body, data) // テンプレートの実行
	if err != nil {
		err := errors.New("テンプレートの実行に失敗しました.: " + err.Error())
		return err
	}

	// メールの内容
	header := "MIME-version: 1.0;\nContent-Type: text/html; charset=\"UTF-8\";\n\n"
	subject := "Subject: パスワード再設定用トークン発行\n"
	port, _ := strconv.Atoi(os.Getenv("SMTP_PORT"))
	msg := subject + header + body.String()

	// SMTPサーバの設定
	smtpServer := fmt.Sprintf("%s:%d", smtpHost, port)
	auth := smtp.CRAMMD5Auth(smtpUser, smtpPass)

	// メールの送信
	err = smtp.SendMail(
		smtpServer,
		auth,
		smtpUser,
		[]string{to},
		[]byte(msg),
	)

	if err != nil {
		err := errors.New("メールの送信に失敗しました.: " + err.Error())
		return err
	}

	return nil
}

func DeleteExpiredTokensTicker() {
	t := time.NewTicker(1 * time.Hour) // 24時間ごとに実行
	// defer t.Stop()

	go func() {
		for {
			select {
			case <-t.C:
				models.DeleteExpiredTokens()
				fmt.Println("Expired tokens deleted.")
			}
		}
	}()
}

HTMLで作成したメールのテンプレート

少しかしこまった形式でテンプレート作成してみました。サービスではこんな感じのメールきますよね。それっぽい感じになってると思います。

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <title>パスワード再設定</title>
    <style>
        body {
            font-family: Arial, sans-serif;
            background-color: #3e3d3d;
            color: #ffffff;
            line-height: 1.6;
        }

        .container {
            max-width: 600px;
            margin: 20px auto;
            padding: 20px;
            background: #3e3d3d;
        }

        .button {
            display: inline-block;
            padding: 10px 20px;
            margin-top: 20px;
            background: #ffe204;
            color: #0f0038;
            text-decoration: none;
            border-radius: 5px;
        }
    </style>
</head>

<body>
    <div class="container">
        <h2>パスワード再設定のリクエスト</h2>
        <p>このメールは、アカウントのパスワード再設定リクエストに応じて送信されています。</p>
        <p>パスワードをリセットするには、以下のリンクをクリックしてください。</p>
        <a href="{{.ResetURL}}" class="button">パスワードをリセットする</a>
        <p>このリンクは次の24時間有効です。</p>
        <p>もしこのリクエストを行っていない場合は、このメールを無視してください。</p>
        <p>安全なサービスの提供のためにご協力ありがとうございます。</p>
        <p>敬具,</p>
        <p>xxxx</p>
    </div>
</body>

</html>

まとめ

  • 今回始めてGo書きましたが、思ったより簡単にかけました。
  • 今後は、デザインパターンを意識したより可読性の高いコードをかけるようにしていきます。仕事でのバックエンドの開発はJavaで書くので、Java関係の新しい技術も投稿していければなと思います。

Discussion