207 lines
7.4 KiB
Go
207 lines
7.4 KiB
Go
// Copyright 2022 E99p1ant. All rights reserved.
|
|
// Use of this source code is governed by a MIT-style
|
|
// license that can be found in the LICENSE file.
|
|
|
|
package auth
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"time"
|
|
|
|
"github.com/flamego/cache"
|
|
"github.com/flamego/recaptcha"
|
|
"github.com/pkg/errors"
|
|
"github.com/sirupsen/logrus"
|
|
"github.com/thanhpk/randstr"
|
|
|
|
"github.com/tamakyi/TamaBox/internal/captcha"
|
|
"github.com/tamakyi/TamaBox/internal/context"
|
|
"github.com/tamakyi/TamaBox/internal/db"
|
|
"github.com/tamakyi/TamaBox/internal/form"
|
|
"github.com/tamakyi/TamaBox/internal/i18n"
|
|
"github.com/tamakyi/TamaBox/internal/mail"
|
|
"github.com/tamakyi/TamaBox/internal/shared"
|
|
)
|
|
|
|
func ForgotPassword(ctx context.Context) {
|
|
ctx.Success("auth/forgot-password")
|
|
}
|
|
|
|
func ForgotPasswordAction(ctx context.Context, f form.ForgotPassword, cache cache.Cache, recaptcha recaptcha.RecaptchaV3) {
|
|
if ctx.HasError() {
|
|
ctx.Success("auth/forgot-password")
|
|
return
|
|
}
|
|
|
|
// Check captcha code (unified: recaptcha, turnstile, or math).
|
|
ok, err := captcha.Verify(f.Recaptcha, shared.ResolveClientIP(ctx.Request().Request), ctx.Session, recaptcha, ctx.Lang())
|
|
if err != nil {
|
|
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to check captcha")
|
|
ctx.SetErrorFlash(err.Error())
|
|
ctx.Redirect("/forgot-password")
|
|
return
|
|
}
|
|
if !ok {
|
|
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "captcha.error"))
|
|
ctx.Redirect("/forgot-password")
|
|
return
|
|
}
|
|
|
|
user, err := db.Users.GetByEmail(ctx.Request().Context(), f.Email)
|
|
if err != nil {
|
|
if errors.Is(err, db.ErrUserNotExists) {
|
|
// 防邮箱枚举:不区分邮箱是否存在,统一渲染"已发送"提示页(不实际发信)。
|
|
ctx.Data["email"] = f.Email
|
|
ctx.Success("auth/forgot-password-sent")
|
|
return
|
|
}
|
|
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to get user by email")
|
|
ctx.SetInternalErrorFlash()
|
|
ctx.Redirect("/forgot-password")
|
|
return
|
|
}
|
|
|
|
emailSentCacheKey := "forgot-password-email-sent:" + user.Email
|
|
_, err = cache.Get(ctx.Request().Context(), emailSentCacheKey)
|
|
if err != nil {
|
|
if !errors.Is(err, os.ErrNotExist) {
|
|
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to read password recovery email sent cache")
|
|
}
|
|
} else {
|
|
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "auth.errEmailFrequent"))
|
|
ctx.Redirect("/forgot-password")
|
|
return
|
|
}
|
|
|
|
// SEC-05:签发新令牌前,先尝试删除该用户上一次的找回令牌。
|
|
// 用 userID → code 反查键实现:新令牌签发后旧令牌的反查键被覆盖,
|
|
// 旧 code 在 24h 内虽然物理上还在缓存里,但已经不在用户的"当前令牌"集合中,
|
|
// checkRecoverPasswordCode 会拒绝使用。
|
|
userCodeKey := "forgot-password-user-code:" + fmt.Sprint(user.ID)
|
|
if oldCodeItf, err := cache.Get(ctx.Request().Context(), userCodeKey); err == nil {
|
|
if oldCode, ok := oldCodeItf.(string); ok && oldCode != "" {
|
|
oldCodeKey := "forgot-password-recovery-code:" + oldCode
|
|
if err := cache.Delete(ctx.Request().Context(), oldCodeKey); err != nil {
|
|
logrus.WithContext(ctx.Request().Context()).WithError(err).Warn("Failed to delete old recovery code")
|
|
}
|
|
}
|
|
}
|
|
|
|
code := randstr.String(64)
|
|
recoveryCodeCacheKey := "forgot-password-recovery-code:" + code
|
|
if err := cache.Set(ctx.Request().Context(), recoveryCodeCacheKey, user.ID, 24*time.Hour); err != nil {
|
|
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to set password recovery code cache")
|
|
ctx.SetInternalErrorFlash()
|
|
ctx.Redirect("/forgot-password")
|
|
return
|
|
}
|
|
|
|
// 记录该用户当前的找回令牌 code,供下次签发时撤销。
|
|
if err := cache.Set(ctx.Request().Context(), userCodeKey, code, 24*time.Hour); err != nil {
|
|
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to set user recovery code cache")
|
|
// 尽力回滚刚写入的令牌,避免孤儿
|
|
_ = cache.Delete(ctx.Request().Context(), recoveryCodeCacheKey)
|
|
ctx.SetInternalErrorFlash()
|
|
ctx.Redirect("/forgot-password")
|
|
return
|
|
}
|
|
|
|
if err := mail.SendPasswordRecoveryMail(user.Email, code); err != nil {
|
|
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to send password recovery mail")
|
|
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "auth.errEmailFailed"))
|
|
ctx.Redirect("/forgot-password")
|
|
return
|
|
}
|
|
|
|
if err := cache.Set(ctx.Request().Context(), emailSentCacheKey, time.Now(), 2*time.Minute); err != nil {
|
|
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to set password recovery email cache")
|
|
}
|
|
|
|
ctx.Data["email"] = user.Email
|
|
ctx.Success("auth/forgot-password-sent")
|
|
}
|
|
|
|
func checkRecoverPasswordCode(ctx context.Context, cache cache.Cache) (*db.User, bool) {
|
|
code := ctx.Query("code")
|
|
recoveryCodeCacheKey := "forgot-password-recovery-code:" + code
|
|
userIDItf, err := cache.Get(ctx.Request().Context(), recoveryCodeCacheKey)
|
|
if err != nil {
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "auth.errCodeExpired"))
|
|
} else {
|
|
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to read password recovery code cache")
|
|
ctx.SetInternalErrorFlash()
|
|
}
|
|
ctx.Redirect("/login")
|
|
return nil, false
|
|
}
|
|
|
|
userID, ok := userIDItf.(uint)
|
|
if !ok {
|
|
logrus.WithContext(ctx.Request().Context()).WithField("user_id_itf", userIDItf).Error("Failed to convert user id interface to uint")
|
|
ctx.SetInternalErrorFlash()
|
|
ctx.Redirect("/login")
|
|
return nil, false
|
|
}
|
|
|
|
user, err := db.Users.GetByID(ctx.Request().Context(), userID)
|
|
if err != nil {
|
|
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "auth.errUserNotFound"))
|
|
ctx.Redirect("/login")
|
|
return nil, false
|
|
}
|
|
|
|
return user, true
|
|
}
|
|
|
|
func RecoverPassword(ctx context.Context, cache cache.Cache) {
|
|
user, ok := checkRecoverPasswordCode(ctx, cache)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
ctx.Data["User"] = user
|
|
ctx.Success("auth/password-recovery")
|
|
}
|
|
|
|
func RecoverPasswordAction(ctx context.Context, cache cache.Cache, f form.RecoverPassword) {
|
|
user, ok := checkRecoverPasswordCode(ctx, cache)
|
|
if !ok {
|
|
return
|
|
}
|
|
if ctx.HasError() {
|
|
ctx.Data["User"] = user
|
|
ctx.Success("auth/password-recovery")
|
|
return
|
|
}
|
|
|
|
// SEC-05:先更新密码,成功后再删除令牌。
|
|
// 旧实现先删令牌再改密码:若密码更新失败,用户必须重新申请。
|
|
// 现在密码更新成功才删令牌;即使删令牌失败,密码已改,
|
|
// 旧令牌也不再有用(重新登录需要新密码)。
|
|
if err := db.Users.UpdatePassword(ctx.Request().Context(), user.ID, f.NewPassword); err != nil {
|
|
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to update user password")
|
|
ctx.SetInternalErrorFlash()
|
|
ctx.Refresh()
|
|
return
|
|
}
|
|
|
|
code := ctx.Query("code")
|
|
recoveryCodeCacheKey := "forgot-password-recovery-code:" + code
|
|
if err := cache.Delete(ctx.Request().Context(), recoveryCodeCacheKey); err != nil {
|
|
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to delete password recovery code cache")
|
|
// 令牌删除失败不阻断流程:密码已改,旧令牌已经无法用于登录。
|
|
// 但记录日志便于排查缓存异常。
|
|
}
|
|
|
|
// 同时清理 user → code 反查键,避免下次申请时尝试删除已不存在的旧 code。
|
|
userCodeKey := "forgot-password-user-code:" + fmt.Sprint(user.ID)
|
|
if err := cache.Delete(ctx.Request().Context(), userCodeKey); err != nil {
|
|
logrus.WithContext(ctx.Request().Context()).WithError(err).Warn("Failed to delete user recovery code mapping")
|
|
}
|
|
|
|
ctx.SetSuccessFlash(i18n.T(ctx.Lang(), "auth.errPasswordChanged"))
|
|
ctx.Redirect("/login")
|
|
}
|