今回は自身で開発・運営している「Hello Books」で利用している技術について紹介したいと思います。
Hello Booksについて
- エンジニア向け技術書レビューサービス
- 購入前の判断にエンジニアのレビューを役立てることができる
- アカウントを作成することで自身もレビューを投稿したり、技術書をブックマークしたりできる
- 詳細な条件による技術書の検索が可能
パスワードリセット機能について
一般的なwebサービスでは、サインイン時にパスワードを忘れてしまった場合などに備えてパスワードをリセットする機能が備わっていると思います。Hello Booksも例に漏れず、ユーザのパスワードをリセットする機能を提供しています。
今回はフロントエンド側の実装は省略して、サーバサイド側の実装に焦点を当てて見ていこうと思います。リセット処理の大まかなフローは下記の様になります。
ユーザがパスワードのリセットをリクエスト
↓
入力されたメールアドレスにリセットフォームのURLを含んだメールを送信
↓
設定されているパスワードリセットトークンの有効期限が切れていない場合にはリセットフォームを表示(有効期限が切れている場合にはその旨を通知してトップ画面にリダイレクト)
↓
入力されたパスワードを新パスワードとして更新
それでは各詳細を見ていこうと思います。
PasswordResetToken
クラス
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を実装しています。
@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
で行っています。
fun createPasswordResetTokenForUser(user: User, token: String) {
val generatedToken = PasswordResetToken(token, user)
PasswordResetTokenRepository.save(generatedToken)
}
トークンの発行処理が完了すると、該当ユーザ宛にリセットフォームを含んだ通知メールを送信します。
パスワードリセットトークン検証API
メール記載のURLがリセットフォームのURLになります。今回の場合は?token=fb2ba4ce-cff5-4ec1-b3e8-47cc874ddc18
がランダムに生成されたパスワードのリセットトークンになります。
上述の通りこのトークンの有効期限は発行から1時間としているので、リセットフォーム表示時にはトークンの検証を行う必要があります。同じくAuthController
に検証APIを実装していきます。
@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を返す様にしています。このレスポンスに基づいてトップページへのリダイレクトをフロント側で制御しています。
トークンが有効であるある場合にはリセットフォームを表示し、パスワードのリセットを認めます。
パスワード更新処理にあたってもトークンをリクエストに含める様にしていて、もしトークンが失効している場合には更新を認めない様にサーバサイドで制御しています(実装は割愛します)。
最後に
最後まで読んでくださり、ありがとうございました。最低限のパスワードリセット機能であればシンプルに実装できることがお分かりいただけたと思います。よろしければHello Booksにも是非遊びに来てください!