パスワードリセット機能を実装してみた

今回は自身で開発・運営している「Hello Books」で利用している技術について紹介したいと思います。  
  
#### [Hello Books](https://hellobooks.org)について
https://hellobooks.org

* エンジニア向け技術書レビューサービス
* 購入前の判断にエンジニアのレビューを役立てることができる
* アカウントを作成することで自身もレビューを投稿したり、技術書をブックマークしたりできる
* 詳細な条件による技術書の検索が可能
![スクリーンショット 2021-05-27 10.20.00.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/521599/52eb5728-a133-3759-011d-95571d1f006d.png)


### パスワードリセット機能について
一般的なwebサービスでは、サインイン時にパスワードを忘れてしまった場合などに備えてパスワードをリセットする機能が備わっていると思います。[Hello Books](https://hellobooks.org)も例に漏れず、ユーザのパスワードをリセットする機能を提供しています。

* リセットフォームのリクエストを受け付けるモーダル
![スクリーンショット 2021-05-27 17.05.11.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/521599/1ee1d26f-f971-5b3d-adf8-a07d2e683e56.png)
* 実際に新パスワードを設定するリセットフォーム
![スクリーンショット 2021-05-27 17.16.48.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/521599/389be237-91b9-60d9-ec3a-cd375560d2ec.png)

今回はフロントエンド側の実装は省略して、サーバサイド側の実装に焦点を当てて見ていこうと思います。リセット処理の大まかなフローは下記の様になります。

`ユーザがパスワードのリセットをリクエスト`
↓
`入力されたメールアドレスにリセットフォームのURLを含んだメールを送信`
↓
`設定されているパスワードリセットトークンの有効期限が切れていない場合にはリセットフォームを表示（有効期限が切れている場合にはその旨を通知してトップ画面にリダイレクト）`
↓
`入力されたパスワードを新パスワードとして更新`

それでは各詳細を見ていこうと思います。

#### `PasswordResetToken`クラス
```kotlin
const val UNSAVED_VALUE = -1L

const val EXPIRATION_HOUR_UNIT = 1.toLong()

@Entity
class PasswordResetToken(
    val token: String,
    @OneToOne(targetEntity = User::class, fetch = FetchType.EAGER)
    @JoinColumn(nullable = false, name = "user_id")
    val user: User,
) {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private val id: Long = UNSAVED_VALUE

    private val expiryDate: LocalDateTime = LocalDateTime.now().plusHours(EXPIRATION_HOUR_UNIT)

    fun delete() = PasswordResetTokenRepository.delete(this)

    /**
     * @return true if this token is already expired
     */
    fun isExpired(): Boolean {
        val now = LocalDateTime.now()
        return this.expiryDate.isBefore(now)
    }
}
```
まずパスワードリセットトークンに対応するモデルを実装していきます。jpaを利用している関係で色々アノテーションがついていますが、実装自体はシンプルだと思います。
`token`は実際のトークンの値で、`user`はパスワードリセットトークンに紐づいている実際のユーザに該当します。`expiryDate`はトークンの失効日時を表していて、発行から1時間後に設定しています。
冗長になってしまうため実装は省略しますが、`PasswordResetTokenRepository`はEntityに対応する一般的なRepositoryクラスになります。

#### パスワードリセット受け付けAPI

認証関連のAPIは`AuthController`に実装をしているのですが、そちらにユーザからのパスワードリセットを受け付けるAPIを実装しています。

```kotlin
    @PostMapping("/reset_password")
    fun resetPassword(@RequestBody params: Map<String, Any>): ResponseEntity<Any> {
        val requestParams = RequestParams(params)
        val email = UserEmail(requestParams.getString("email"))

        val user: User
        try {
            user = UserRepository.findByEmail(email)
        } catch (e: EntityNotFoundException) {
            // when user associated with the email is not found, return 400 BadRequest
            return ResponseEntity
                .badRequest()
                .body(MessageResponse("入力されたメールアドレスは登録されていないようです"))
        }

        val token = UUID.randomUUID().toString()

        createPasswordResetTokenForUser(user, token)

        sender.send(
            constructEmailText(
                url,
                token,
                user
            )
        )

        return ResponseEntity.ok(MessageResponse("Mail Sent!"))
    }
```

リクエストに含まれるメールアドレスからトークン発行対象のユーザを取得し、生成したトークンと紐づけてからています。実際の紐付け処理は`createPasswordResetTokenForUser`で行っています。

```kotlin
fun createPasswordResetTokenForUser(user: User, token: String) {
    val generatedToken = PasswordResetToken(token, user)
    PasswordResetTokenRepository.save(generatedToken)
}
```

トークンの発行処理が完了すると、該当ユーザ宛にリセットフォームを含んだ通知メールを送信します。

![スクリーンショット 2021-05-27 18.31.03.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/521599/706c189f-e49c-8c04-29f9-dc00445840ff.png)

#### パスワードリセットトークン検証API
メール記載のURLがリセットフォームのURLになります。今回の場合は`?token=fb2ba4ce-cff5-4ec1-b3e8-47cc874ddc18`がランダムに生成されたパスワードのリセットトークンになります。
上述の通りこのトークンの有効期限は発行から1時間としているので、リセットフォーム表示時にはトークンの検証を行う必要があります。同じく`AuthController`に検証APIを実装していきます。

```kotlin
    @GetMapping("/validate_token")
    fun validateToken(@RequestParam("token") token: String): ResponseEntity<Any> {
        try {
            validatePasswordResetToken(token)
        } catch (e: TokenExpiredException) {
            // if token is invalid, return 400 BadRequest
            return ResponseEntity
                .badRequest()
                .body(MessageResponse("リセットフォームの有効期限が既に切れています"))
        } catch (e: EntityNotFoundException) {
            // if token is not found, return 400 BadRequest
            return ResponseEntity
                .badRequest()
                .body(MessageResponse("無効なトークンです"))
        }

        return ResponseEntity.ok(MessageResponse("PasswordResetToken is not expired yet: $token"))
    }

    /**
     * validate if token is expired or not
     */
    fun validatePasswordResetToken(token: String) {
        val foundToken = PasswordResetTokenRepository.findByToken(token)
        if (foundToken.isExpired()) throw TokenExpiredException("PasswordResetToken is already expired: $token")
    }
```

`validatePasswordResetToken`メソッドでトークンの有効期限が切れていないか確認をして、もし失効している場合にはステータスコード400を返す様にしています。このレスポンスに基づいてトップページへのリダイレクトをフロント側で制御しています。
トークンが有効であるある場合にはリセットフォームを表示し、パスワードのリセットを認めます。
![スクリーンショット 2021-05-27 17.16.48.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/521599/a182a8c1-ca2c-d197-566a-c045a93d90ba.png)  

パスワード更新処理にあたってもトークンをリクエストに含める様にしていて、もしトークンが失効している場合には更新を認めない様にサーバサイドで制御しています（実装は割愛します）。

##### 最後に
最後まで読んでくださり、ありがとうございました。最低限のパスワードリセット機能であればシンプルに実装できることがお分かりいただけたと思います。よろしければ[Hello Books](https://hellobooks.org)にも是非遊びに来てください！