diff --git a/.idea/workspace.xml b/.idea/workspace.xml index 49b501b..f46b1c5 100644 --- a/.idea/workspace.xml +++ b/.idea/workspace.xml @@ -4,9 +4,18 @@ - @@ -177,7 +185,8 @@ - true diff --git a/pkg/internal/models/tokens.go b/pkg/internal/models/tokens.go index 760d7b4..35d1417 100644 --- a/pkg/internal/models/tokens.go +++ b/pkg/internal/models/tokens.go @@ -7,6 +7,7 @@ type MagicTokenType = int8 const ( ConfirmMagicToken = MagicTokenType(iota) RegistrationMagicToken + ResetPasswordMagicToken ) type MagicToken struct { diff --git a/pkg/internal/server/api/accounts_api.go b/pkg/internal/server/api/accounts_api.go index d2531aa..a34815a 100644 --- a/pkg/internal/server/api/accounts_api.go +++ b/pkg/internal/server/api/accounts_api.go @@ -123,23 +123,6 @@ func editUserinfo(c *fiber.Ctx) error { return c.SendStatus(fiber.StatusOK) } -func killTicket(c *fiber.Ctx) error { - if err := exts.EnsureAuthenticated(c); err != nil { - return err - } - user := c.Locals("user").(models.Account) - id, _ := c.ParamsInt("ticketId", 0) - - if err := database.C.Delete(&models.AuthTicket{}, &models.AuthTicket{ - BaseModel: models.BaseModel{ID: uint(id)}, - AccountID: user.ID, - }).Error; err != nil { - return fiber.NewError(fiber.StatusNotFound, err.Error()) - } - - return c.SendStatus(fiber.StatusOK) -} - func doRegister(c *fiber.Ctx) error { var data struct { Name string `json:"name" validate:"required,lowercase,alphanum,min=4,max=16"` diff --git a/pkg/internal/server/api/factors_api.go b/pkg/internal/server/api/factors_api.go index 99bf731..bc5128a 100644 --- a/pkg/internal/server/api/factors_api.go +++ b/pkg/internal/server/api/factors_api.go @@ -2,6 +2,7 @@ package api import ( "fmt" + "git.solsynth.dev/hydrogen/passport/pkg/internal/server/exts" "git.solsynth.dev/hydrogen/passport/pkg/internal/services" "github.com/gofiber/fiber/v2" ) @@ -40,3 +41,43 @@ func requestFactorToken(c *fiber.Ctx) error { return c.SendStatus(fiber.StatusOK) } } + +func requestResetPassword(c *fiber.Ctx) error { + var data struct { + UserID uint `json:"user_id" validate:"required"` + } + + if err := exts.BindAndValidate(c, &data); err != nil { + return err + } + + user, err := services.GetAccount(data.UserID) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, err.Error()) + } + + if err = services.CheckAbleToResetPassword(user); err != nil { + return fiber.NewError(fiber.StatusBadRequest, err.Error()) + } else if err = services.RequestResetPassword(user); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, err.Error()) + } + + return c.SendStatus(fiber.StatusOK) +} + +func confirmResetPassword(c *fiber.Ctx) error { + var data struct { + Code string `json:"code" validate:"required"` + NewPassword string `json:"new_password" validate:"required"` + } + + if err := exts.BindAndValidate(c, &data); err != nil { + return err + } + + if err := services.ConfirmResetPassword(data.Code, data.NewPassword); err != nil { + return fiber.NewError(fiber.StatusBadRequest, err.Error()) + } + + return c.SendStatus(fiber.StatusOK) +} diff --git a/pkg/internal/server/api/index.go b/pkg/internal/server/api/index.go index 06158c7..ecd880b 100644 --- a/pkg/internal/server/api/index.go +++ b/pkg/internal/server/api/index.go @@ -34,6 +34,8 @@ func MapAPIs(app *fiber.App) { me.Delete("/tickets/:ticketId", killTicket) me.Post("/confirm", doRegisterConfirm) + me.Post("/reset-password", requestResetPassword) + me.Patch("/reset-password", confirmResetPassword) me.Get("/status", getMyselfStatus) me.Post("/status", setStatus) diff --git a/pkg/internal/server/api/security_api.go b/pkg/internal/server/api/security_api.go index fba9867..715c822 100644 --- a/pkg/internal/server/api/security_api.go +++ b/pkg/internal/server/api/security_api.go @@ -38,3 +38,20 @@ func getTickets(c *fiber.Ctx) error { "data": tickets, }) } + +func killTicket(c *fiber.Ctx) error { + if err := exts.EnsureAuthenticated(c); err != nil { + return err + } + user := c.Locals("user").(models.Account) + id, _ := c.ParamsInt("ticketId", 0) + + if err := database.C.Delete(&models.AuthTicket{}, &models.AuthTicket{ + BaseModel: models.BaseModel{ID: uint(id)}, + AccountID: user.ID, + }).Error; err != nil { + return fiber.NewError(fiber.StatusNotFound, err.Error()) + } + + return c.SendStatus(fiber.StatusOK) +} diff --git a/pkg/internal/services/accounts.go b/pkg/internal/services/accounts.go index ab764ca..1d81849 100644 --- a/pkg/internal/services/accounts.go +++ b/pkg/internal/services/accounts.go @@ -101,6 +101,8 @@ func ConfirmAccount(code string) error { token, err := ValidateMagicToken(code, models.ConfirmMagicToken) if err != nil { return err + } else if token.AccountID == nil { + return fmt.Errorf("magic token didn't assign a valid account") } var user models.Account @@ -134,6 +136,61 @@ func ConfirmAccount(code string) error { }) } +func CheckAbleToResetPassword(user models.Account) error { + var count int64 + if err := database.C. + Where("account_id = ?", user.ID). + Where("expired_at < ?", time.Now()). + Model(&models.MagicToken{}). + Count(&count).Error; err != nil { + return fmt.Errorf("unable to check reset password ability: %v", err) + } else if count == 0 { + return fmt.Errorf("you requested reset password recently") + } + + return nil +} + +func RequestResetPassword(user models.Account) error { + if tk, err := NewMagicToken( + models.ResetPasswordMagicToken, + &user, + lo.ToPtr(time.Now().Add(24*time.Hour)), + ); err != nil { + return err + } else if err := NotifyMagicToken(tk); err != nil { + log.Error(). + Err(err). + Str("code", tk.Code). + Uint("user", user.ID). + Msg("Failed to notify password reset magic token...") + } + + return nil +} + +func ConfirmResetPassword(code, newPassword string) error { + token, err := ValidateMagicToken(code, models.ResetPasswordMagicToken) + if err != nil { + return err + } else if token.AccountID == nil { + return fmt.Errorf("magic token didn't assign a valid account") + } + + factor, err := GetPasswordTypeFactor(*token.AccountID) + if err != nil { + factor = models.AuthFactor{ + Type: models.PasswordAuthFactor, + Secret: HashPassword(newPassword), + AccountID: *token.AccountID, + } + } else { + factor.Secret = HashPassword(newPassword) + } + + return database.C.Save(&factor).Error +} + func DeleteAccount(id uint) error { tx := database.C.Begin() diff --git a/pkg/internal/services/tokens.go b/pkg/internal/services/tokens.go index 9dd9419..8c9bcfa 100644 --- a/pkg/internal/services/tokens.go +++ b/pkg/internal/services/tokens.go @@ -27,6 +27,21 @@ Once again, thank you for choosing us. We look forward to serving you and hope y Best regards, %s` +const ResetPasswordTemplate = `Dear %s, + +We received a request to reset the password for your account at %s. If you did not request a password reset, please ignore this email. + +To confirm your password reset request and create a new password, please use the link below: + +%s + +This link will expire in 24 hours. If you do not reset your password within this time frame, you will need to submit a new password reset request. + +If you have any questions or need further assistance, please do not hesitate to contact our support team. + +Best regards, +%s` + func ValidateMagicToken(code string, mode models.MagicTokenType) (models.MagicToken, error) { var tk models.MagicToken if err := database.C.Where(models.MagicToken{Code: code, Type: mode}).First(&tk).Error; err != nil { @@ -74,7 +89,7 @@ func NotifyMagicToken(token models.MagicToken) error { var content string switch token.Type { case models.ConfirmMagicToken: - link := fmt.Sprintf("https://%s/me/confirm?tk=%s", viper.GetString("domain"), token.Code) + link := fmt.Sprintf("https://%s/flow/confirm?code=%s", viper.GetString("domain"), token.Code) subject = fmt.Sprintf("[%s] Confirm your registration", viper.GetString("name")) content = fmt.Sprintf( ConfirmRegistrationTemplate, @@ -84,6 +99,16 @@ func NotifyMagicToken(token models.MagicToken) error { link, viper.GetString("maintainer"), ) + case models.ResetPasswordMagicToken: + link := fmt.Sprintf("https://%s/flow/password-reset?code=%s", viper.GetString("domain"), token.Code) + subject = fmt.Sprintf("[%s] Reset your password", viper.GetString("name")) + content = fmt.Sprintf( + ResetPasswordTemplate, + user.Name, + viper.GetString("name"), + link, + viper.GetString("maintainer"), + ) default: return fmt.Errorf("unsupported magic token type to notify") } diff --git a/web/src/router/index.ts b/web/src/router/index.ts index 65d63ed..f506750 100755 --- a/web/src/router/index.ts +++ b/web/src/router/index.ts @@ -51,17 +51,28 @@ const router = createRouter({ meta: { public: true, title: "Sign up" }, }, { - path: "authorize", + path: "/authorize", name: "oauth.authorize", component: () => import("@/views/auth/authorize.vue"), }, ], }, { - path: "/users/me/confirm", - name: "callback.confirm", - component: () => import("@/views/confirm.vue"), - meta: { public: true, title: "Confirm registration" }, + path: "/flow", + children: [ + { + path: "confirm", + name: "callback.confirm", + component: () => import("@/views/flow/confirm.vue"), + meta: { public: true, title: "Confirm registration" }, + }, + { + path: "password-reset", + name: "callback.password-reset", + component: () => import("@/views/flow/password-reset.vue"), + meta: { public: true, title: "Reset password" }, + }, + ], }, ], }) diff --git a/web/src/views/flow/confirm.vue b/web/src/views/flow/confirm.vue new file mode 100755 index 0000000..0ddb169 --- /dev/null +++ b/web/src/views/flow/confirm.vue @@ -0,0 +1,104 @@ + + + + + diff --git a/web/src/views/confirm.vue b/web/src/views/flow/password-reset.vue similarity index 100% rename from web/src/views/confirm.vue rename to web/src/views/flow/password-reset.vue