From 0c7d4ef0ea7ac7859b255b3ee6f670903ad9f0b1 Mon Sep 17 00:00:00 2001 From: Rogee Date: Thu, 25 Dec 2025 09:58:34 +0800 Subject: [PATCH] feat: portal auth login and password reset --- backend/app/http/web/auth.go | 193 +++++++++++++- backend/app/http/web/dto/password_reset.go | 45 ++++ backend/app/http/web/routes.gen.go | 15 ++ backend/app/middlewares/user.go | 4 +- backend/app/services/user.go | 26 ++ backend/docs/docs.go | 169 +++++++++++++ backend/docs/swagger.json | 169 +++++++++++++ backend/docs/swagger.yaml | 111 ++++++++ frontend/portal/src/App.vue | 1 + frontend/portal/src/layout/AppLayout.vue | 1 - frontend/portal/src/router/index.js | 2 +- frontend/portal/src/service/auth.js | 21 ++ .../src/views/pages/auth/ForgotPassword.vue | 237 ++++++++++++++++++ 13 files changed, 989 insertions(+), 5 deletions(-) create mode 100644 backend/app/http/web/dto/password_reset.go create mode 100644 frontend/portal/src/views/pages/auth/ForgotPassword.vue diff --git a/backend/app/http/web/auth.go b/backend/app/http/web/auth.go index 4e97d7c..2ed919e 100644 --- a/backend/app/http/web/auth.go +++ b/backend/app/http/web/auth.go @@ -1,9 +1,14 @@ package web import ( + "crypto/rand" "errors" + "fmt" + "math/big" "regexp" "strings" + "sync" + "time" "quyun/v2/app/errorx" "quyun/v2/app/http/web/dto" @@ -13,6 +18,7 @@ import ( "quyun/v2/providers/jwt" "github.com/gofiber/fiber/v3" + "github.com/google/uuid" "gorm.io/gorm" ) @@ -21,7 +27,33 @@ type auth struct { jwt *jwt.JWT } -var reUsername = regexp.MustCompile(`^[a-zA-Z0-9_]{3,32}$`) +var ( + reUsername = regexp.MustCompile(`^[a-zA-Z0-9_]{3,32}$`) + rePhone = regexp.MustCompile(`^[0-9]{6,20}$`) +) + +type passwordResetState struct { + code string + codeExpire time.Time + lastSentAt time.Time + resetToken string + tokenExpire time.Time +} + +var passwordResetStore = struct { + mu sync.Mutex + phoneToItem map[string]*passwordResetState + tokenToPhone map[string]string +}{ + phoneToItem: make(map[string]*passwordResetState), + tokenToPhone: make(map[string]string), +} + +const ( + passwordResetCodeTTL = 5 * time.Minute + passwordResetTokenTTL = 10 * time.Minute + passwordResetSendGap = 60 * time.Second +) // Login 用户登录(平台侧,非超级管理员)。 // @@ -52,6 +84,165 @@ func (ctl *auth) login(ctx fiber.Ctx, form *dto.LoginForm) (*dto.LoginResponse, return &dto.LoginResponse{Token: token}, nil } +// PasswordResetSendSMS 找回密码:发送短信验证码(预留:当前返回验证码用于前端弹窗展示)。 +// +// @Summary 找回密码-发送短信验证码 +// @Tags Web +// @Accept json +// @Produce json +// @Param form body dto.PasswordResetSendSMSForm true "form" +// @Success 200 {object} dto.PasswordResetSendSMSResponse "成功" +// @Router /v1/auth/password/reset/sms [post] +// @Bind form body +func (ctl *auth) passwordResetSendSMS(ctx fiber.Ctx, form *dto.PasswordResetSendSMSForm) (*dto.PasswordResetSendSMSResponse, error) { + phone := strings.TrimSpace(form.Phone) + if phone == "" { + return nil, errorx.ErrMissingParameter.WithMsg("请输入手机号") + } + if !rePhone.MatchString(phone) { + return nil, errorx.ErrInvalidParameter.WithMsg("手机号格式不正确") + } + + passwordResetStore.mu.Lock() + defer passwordResetStore.mu.Unlock() + + now := time.Now() + item := passwordResetStore.phoneToItem[phone] + if item == nil { + item = &passwordResetState{} + passwordResetStore.phoneToItem[phone] = item + } + + if !item.lastSentAt.IsZero() { + elapsed := now.Sub(item.lastSentAt) + if elapsed < passwordResetSendGap { + remain := int((passwordResetSendGap - elapsed).Seconds()) + if remain < 1 { + remain = 1 + } + return nil, errorx.ErrTooManyRequests.WithMsgf("操作太频繁,请 %d 秒后再试", remain) + } + } + + n, err := rand.Int(rand.Reader, big.NewInt(1000000)) + if err != nil { + return nil, errorx.Wrap(err).WithMsg("验证码生成失败,请稍后再试") + } + code := fmt.Sprintf("%06d", n.Int64()) + + item.code = code + item.codeExpire = now.Add(passwordResetCodeTTL) + item.lastSentAt = now + item.resetToken = "" + item.tokenExpire = time.Time{} + + return &dto.PasswordResetSendSMSResponse{ + NextSendSeconds: int(passwordResetSendGap.Seconds()), + Code: code, + }, nil +} + +// PasswordResetVerify 找回密码:校验短信验证码。 +// +// @Summary 找回密码-校验验证码 +// @Tags Web +// @Accept json +// @Produce json +// @Param form body dto.PasswordResetVerifyForm true "form" +// @Success 200 {object} dto.PasswordResetVerifyResponse "成功" +// @Router /v1/auth/password/reset/verify [post] +// @Bind form body +func (ctl *auth) passwordResetVerify(ctx fiber.Ctx, form *dto.PasswordResetVerifyForm) (*dto.PasswordResetVerifyResponse, error) { + phone := strings.TrimSpace(form.Phone) + if phone == "" { + return nil, errorx.ErrMissingParameter.WithMsg("请输入手机号") + } + if !rePhone.MatchString(phone) { + return nil, errorx.ErrInvalidParameter.WithMsg("手机号格式不正确") + } + code := strings.TrimSpace(form.Code) + if code == "" { + return nil, errorx.ErrMissingParameter.WithMsg("请输入验证码") + } + + passwordResetStore.mu.Lock() + defer passwordResetStore.mu.Unlock() + + now := time.Now() + item := passwordResetStore.phoneToItem[phone] + if item == nil || item.code == "" || item.codeExpire.IsZero() || now.After(item.codeExpire) { + return nil, errorx.ErrPreconditionFailed.WithMsg("验证码已过期,请重新获取") + } + if item.code != code { + return nil, errorx.ErrInvalidParameter.WithMsg("验证码错误,请重新输入") + } + + // 创建一次性重置令牌,并清理验证码,避免复用。 + resetToken := uuid.NewString() + item.resetToken = resetToken + item.tokenExpire = now.Add(passwordResetTokenTTL) + item.code = "" + item.codeExpire = time.Time{} + + passwordResetStore.tokenToPhone[resetToken] = phone + + return &dto.PasswordResetVerifyResponse{ResetToken: resetToken}, nil +} + +// PasswordReset 找回密码:重置密码。 +// +// @Summary 找回密码-重置密码 +// @Tags Web +// @Accept json +// @Produce json +// @Param form body dto.PasswordResetForm true "form" +// @Success 200 {object} dto.PasswordResetResponse "成功" +// @Router /v1/auth/password/reset [post] +// @Bind form body +func (ctl *auth) passwordReset(ctx fiber.Ctx, form *dto.PasswordResetForm) (*dto.PasswordResetResponse, error) { + resetToken := strings.TrimSpace(form.ResetToken) + if resetToken == "" { + return nil, errorx.ErrMissingParameter.WithMsg("请先完成验证码校验") + } + if form.Password == "" || form.ConfirmPassword == "" { + return nil, errorx.ErrMissingParameter.WithMsg("请输入新密码并确认") + } + if len(form.Password) < 8 { + return nil, errorx.ErrParameterTooShort.WithMsg("密码至少 8 位,请设置更安全的密码") + } + if form.Password != form.ConfirmPassword { + return nil, errorx.ErrInvalidParameter.WithMsg("两次输入的密码不一致,请重新确认") + } + + passwordResetStore.mu.Lock() + phone, ok := passwordResetStore.tokenToPhone[resetToken] + item := passwordResetStore.phoneToItem[phone] + now := time.Now() + if !ok || phone == "" || item == nil || item.resetToken != resetToken || item.tokenExpire.IsZero() || now.After(item.tokenExpire) { + passwordResetStore.mu.Unlock() + return nil, errorx.ErrTokenInvalid.WithMsg("重置会话已失效,请重新获取验证码") + } + // 令牌一次性使用 + delete(passwordResetStore.tokenToPhone, resetToken) + item.resetToken = "" + item.tokenExpire = time.Time{} + passwordResetStore.mu.Unlock() + + // 当前版本将手机号视为用户名。 + if _, err := services.User.FindByUsername(ctx, phone); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, errorx.ErrRecordNotFound.WithMsg("该手机号尚未注册") + } + return nil, errorx.Wrap(err).WithMsg("用户信息校验失败,请稍后再试") + } + + if err := services.User.ResetPasswordByUsername(ctx, phone, form.Password); err != nil { + return nil, errorx.Wrap(err).WithMsg("重置密码失败,请稍后再试") + } + + return &dto.PasswordResetResponse{Ok: true}, nil +} + // Register 用户注册(平台侧,非超级管理员)。 // // @Summary 用户注册 diff --git a/backend/app/http/web/dto/password_reset.go b/backend/app/http/web/dto/password_reset.go new file mode 100644 index 0000000..1c0a7e0 --- /dev/null +++ b/backend/app/http/web/dto/password_reset.go @@ -0,0 +1,45 @@ +package dto + +// PasswordResetSendSMSForm 找回密码:发送短信验证码表单。 +type PasswordResetSendSMSForm struct { + // Phone 手机号;当前版本将其作为用户名使用(users.username)。 + Phone string `json:"phone,omitempty"` +} + +// PasswordResetSendSMSResponse 找回密码:发送短信验证码响应。 +type PasswordResetSendSMSResponse struct { + // NextSendSeconds 下次可发送剩余秒数(用于前端 60s 倒计时)。 + NextSendSeconds int `json:"nextSendSeconds"` + // Code 验证码(预留:当前用于前端弹窗展示;正式接入短信后应移除/仅在开发环境返回)。 + Code string `json:"code,omitempty"` +} + +// PasswordResetVerifyForm 找回密码:验证码校验表单。 +type PasswordResetVerifyForm struct { + // Phone 手机号。 + Phone string `json:"phone,omitempty"` + // Code 短信验证码。 + Code string `json:"code,omitempty"` +} + +// PasswordResetVerifyResponse 找回密码:验证码校验响应。 +type PasswordResetVerifyResponse struct { + // ResetToken 重置令牌;验证码校验通过后,用该令牌提交新密码。 + ResetToken string `json:"resetToken"` +} + +// PasswordResetForm 找回密码:重置密码表单。 +type PasswordResetForm struct { + // ResetToken 重置令牌;由验证码校验接口返回。 + ResetToken string `json:"resetToken,omitempty"` + // Password 新密码(明文)。 + Password string `json:"password,omitempty"` + // ConfirmPassword 确认新密码;必须与 Password 一致。 + ConfirmPassword string `json:"confirmPassword,omitempty"` +} + +// PasswordResetResponse 找回密码:重置密码响应。 +type PasswordResetResponse struct { + // Ok 是否成功。 + Ok bool `json:"ok"` +} diff --git a/backend/app/http/web/routes.gen.go b/backend/app/http/web/routes.gen.go index c1b2d31..3e51012 100644 --- a/backend/app/http/web/routes.gen.go +++ b/backend/app/http/web/routes.gen.go @@ -52,6 +52,21 @@ func (r *Routes) Register(router fiber.Router) { r.auth.login, Body[dto.LoginForm]("form"), )) + r.log.Debugf("Registering route: Post /v1/auth/password/reset -> auth.passwordReset") + router.Post("/v1/auth/password/reset"[len(r.Path()):], DataFunc1( + r.auth.passwordReset, + Body[dto.PasswordResetForm]("form"), + )) + r.log.Debugf("Registering route: Post /v1/auth/password/reset/sms -> auth.passwordResetSendSMS") + router.Post("/v1/auth/password/reset/sms"[len(r.Path()):], DataFunc1( + r.auth.passwordResetSendSMS, + Body[dto.PasswordResetSendSMSForm]("form"), + )) + r.log.Debugf("Registering route: Post /v1/auth/password/reset/verify -> auth.passwordResetVerify") + router.Post("/v1/auth/password/reset/verify"[len(r.Path()):], DataFunc1( + r.auth.passwordResetVerify, + Body[dto.PasswordResetVerifyForm]("form"), + )) r.log.Debugf("Registering route: Post /v1/auth/register -> auth.register") router.Post("/v1/auth/register"[len(r.Path()):], DataFunc1( r.auth.register, diff --git a/backend/app/middlewares/user.go b/backend/app/middlewares/user.go index 5ebf68c..c5c97a4 100644 --- a/backend/app/middlewares/user.go +++ b/backend/app/middlewares/user.go @@ -10,7 +10,7 @@ import ( "github.com/gofiber/fiber/v3" ) -func shouldSkipUserJWTAuth(path string, method string) bool { +func shouldSkipUserJWTAuth(path, method string) bool { // 仅对明确的公开接口放行,避免误伤其它路径。 if method != fiber.MethodPost { return false @@ -18,7 +18,7 @@ func shouldSkipUserJWTAuth(path string, method string) bool { p := strings.TrimSuffix(path, "/") switch p { - case "/v1/auth/login", "/v1/auth/register": + case "/v1/auth/login", "/v1/auth/register", "/v1/auth/password/reset/sms", "/v1/auth/password/reset/verify", "/v1/auth/password/reset": return true default: return false diff --git a/backend/app/services/user.go b/backend/app/services/user.go index 372129f..5e88072 100644 --- a/backend/app/services/user.go +++ b/backend/app/services/user.go @@ -16,6 +16,7 @@ import ( "go.ipao.vip/gen" "go.ipao.vip/gen/field" "go.ipao.vip/gen/types" + "golang.org/x/crypto/bcrypt" ) // @provider @@ -84,6 +85,31 @@ func (t *user) Create(ctx context.Context, user *models.User) (*models.User, err return user, nil } +// ResetPasswordByUsername 通过用户名(手机号)重置密码。 +func (t *user) ResetPasswordByUsername(ctx context.Context, username, newPassword string) error { + username = strings.TrimSpace(username) + if username == "" { + return errors.New("username is required") + } + if newPassword == "" { + return errors.New("new_password is required") + } + + m, err := t.FindByUsername(ctx, username) + if err != nil { + return err + } + + // bcrypt hash,避免直接落明文。 + bytes, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost) + if err != nil { + return errors.Wrap(err, "generate password hash failed") + } + + m.Password = string(bytes) + return m.Save(ctx) +} + // SetStatus 设置用户状态(超级管理员侧)。 func (t *user) SetStatus(ctx context.Context, userID int64, status consts.UserStatus) error { m, err := t.FindByID(ctx, userID) diff --git a/backend/docs/docs.go b/backend/docs/docs.go index 37b23bf..d316d3f 100644 --- a/backend/docs/docs.go +++ b/backend/docs/docs.go @@ -3885,6 +3885,105 @@ const docTemplate = `{ } } }, + "/v1/auth/password/reset": { + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Web" + ], + "summary": "找回密码-重置密码", + "parameters": [ + { + "description": "form", + "name": "form", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/dto.PasswordResetForm" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/dto.PasswordResetResponse" + } + } + } + } + }, + "/v1/auth/password/reset/sms": { + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Web" + ], + "summary": "找回密码-发送短信验证码", + "parameters": [ + { + "description": "form", + "name": "form", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/dto.PasswordResetSendSMSForm" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/dto.PasswordResetSendSMSResponse" + } + } + } + } + }, + "/v1/auth/password/reset/verify": { + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Web" + ], + "summary": "找回密码-校验验证码", + "parameters": [ + { + "description": "form", + "name": "form", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/dto.PasswordResetVerifyForm" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/dto.PasswordResetVerifyResponse" + } + } + } + } + }, "/v1/auth/register": { "post": { "consumes": [ @@ -4897,6 +4996,76 @@ const docTemplate = `{ } } }, + "dto.PasswordResetForm": { + "type": "object", + "properties": { + "confirmPassword": { + "description": "ConfirmPassword 确认新密码;必须与 Password 一致。", + "type": "string" + }, + "password": { + "description": "Password 新密码(明文)。", + "type": "string" + }, + "resetToken": { + "description": "ResetToken 重置令牌;由验证码校验接口返回。", + "type": "string" + } + } + }, + "dto.PasswordResetResponse": { + "type": "object", + "properties": { + "ok": { + "description": "Ok 是否成功。", + "type": "boolean" + } + } + }, + "dto.PasswordResetSendSMSForm": { + "type": "object", + "properties": { + "phone": { + "description": "Phone 手机号;当前版本将其作为用户名使用(users.username)。", + "type": "string" + } + } + }, + "dto.PasswordResetSendSMSResponse": { + "type": "object", + "properties": { + "code": { + "description": "Code 验证码(预留:当前用于前端弹窗展示;正式接入短信后应移除/仅在开发环境返回)。", + "type": "string" + }, + "nextSendSeconds": { + "description": "NextSendSeconds 下次可发送剩余秒数(用于前端 60s 倒计时)。", + "type": "integer" + } + } + }, + "dto.PasswordResetVerifyForm": { + "type": "object", + "properties": { + "code": { + "description": "Code 短信验证码。", + "type": "string" + }, + "phone": { + "description": "Phone 手机号。", + "type": "string" + } + } + }, + "dto.PasswordResetVerifyResponse": { + "type": "object", + "properties": { + "resetToken": { + "description": "ResetToken 重置令牌;验证码校验通过后,用该令牌提交新密码。", + "type": "string" + } + } + }, "dto.PurchaseContentForm": { "type": "object", "properties": { diff --git a/backend/docs/swagger.json b/backend/docs/swagger.json index cff9ee3..2b42c17 100644 --- a/backend/docs/swagger.json +++ b/backend/docs/swagger.json @@ -3879,6 +3879,105 @@ } } }, + "/v1/auth/password/reset": { + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Web" + ], + "summary": "找回密码-重置密码", + "parameters": [ + { + "description": "form", + "name": "form", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/dto.PasswordResetForm" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/dto.PasswordResetResponse" + } + } + } + } + }, + "/v1/auth/password/reset/sms": { + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Web" + ], + "summary": "找回密码-发送短信验证码", + "parameters": [ + { + "description": "form", + "name": "form", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/dto.PasswordResetSendSMSForm" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/dto.PasswordResetSendSMSResponse" + } + } + } + } + }, + "/v1/auth/password/reset/verify": { + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Web" + ], + "summary": "找回密码-校验验证码", + "parameters": [ + { + "description": "form", + "name": "form", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/dto.PasswordResetVerifyForm" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/dto.PasswordResetVerifyResponse" + } + } + } + } + }, "/v1/auth/register": { "post": { "consumes": [ @@ -4891,6 +4990,76 @@ } } }, + "dto.PasswordResetForm": { + "type": "object", + "properties": { + "confirmPassword": { + "description": "ConfirmPassword 确认新密码;必须与 Password 一致。", + "type": "string" + }, + "password": { + "description": "Password 新密码(明文)。", + "type": "string" + }, + "resetToken": { + "description": "ResetToken 重置令牌;由验证码校验接口返回。", + "type": "string" + } + } + }, + "dto.PasswordResetResponse": { + "type": "object", + "properties": { + "ok": { + "description": "Ok 是否成功。", + "type": "boolean" + } + } + }, + "dto.PasswordResetSendSMSForm": { + "type": "object", + "properties": { + "phone": { + "description": "Phone 手机号;当前版本将其作为用户名使用(users.username)。", + "type": "string" + } + } + }, + "dto.PasswordResetSendSMSResponse": { + "type": "object", + "properties": { + "code": { + "description": "Code 验证码(预留:当前用于前端弹窗展示;正式接入短信后应移除/仅在开发环境返回)。", + "type": "string" + }, + "nextSendSeconds": { + "description": "NextSendSeconds 下次可发送剩余秒数(用于前端 60s 倒计时)。", + "type": "integer" + } + } + }, + "dto.PasswordResetVerifyForm": { + "type": "object", + "properties": { + "code": { + "description": "Code 短信验证码。", + "type": "string" + }, + "phone": { + "description": "Phone 手机号。", + "type": "string" + } + } + }, + "dto.PasswordResetVerifyResponse": { + "type": "object", + "properties": { + "resetToken": { + "description": "ResetToken 重置令牌;验证码校验通过后,用该令牌提交新密码。", + "type": "string" + } + } + }, "dto.PurchaseContentForm": { "type": "object", "properties": { diff --git a/backend/docs/swagger.yaml b/backend/docs/swagger.yaml index 2e3e80e..1ee88fb 100644 --- a/backend/docs/swagger.yaml +++ b/backend/docs/swagger.yaml @@ -657,6 +657,54 @@ definitions: name: type: string type: object + dto.PasswordResetForm: + properties: + confirmPassword: + description: ConfirmPassword 确认新密码;必须与 Password 一致。 + type: string + password: + description: Password 新密码(明文)。 + type: string + resetToken: + description: ResetToken 重置令牌;由验证码校验接口返回。 + type: string + type: object + dto.PasswordResetResponse: + properties: + ok: + description: Ok 是否成功。 + type: boolean + type: object + dto.PasswordResetSendSMSForm: + properties: + phone: + description: Phone 手机号;当前版本将其作为用户名使用(users.username)。 + type: string + type: object + dto.PasswordResetSendSMSResponse: + properties: + code: + description: Code 验证码(预留:当前用于前端弹窗展示;正式接入短信后应移除/仅在开发环境返回)。 + type: string + nextSendSeconds: + description: NextSendSeconds 下次可发送剩余秒数(用于前端 60s 倒计时)。 + type: integer + type: object + dto.PasswordResetVerifyForm: + properties: + code: + description: Code 短信验证码。 + type: string + phone: + description: Phone 手机号。 + type: string + type: object + dto.PasswordResetVerifyResponse: + properties: + resetToken: + description: ResetToken 重置令牌;验证码校验通过后,用该令牌提交新密码。 + type: string + type: object dto.PurchaseContentForm: properties: idempotency_key: @@ -4223,6 +4271,69 @@ paths: summary: 用户登录 tags: - Web + /v1/auth/password/reset: + post: + consumes: + - application/json + parameters: + - description: form + in: body + name: form + required: true + schema: + $ref: '#/definitions/dto.PasswordResetForm' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + $ref: '#/definitions/dto.PasswordResetResponse' + summary: 找回密码-重置密码 + tags: + - Web + /v1/auth/password/reset/sms: + post: + consumes: + - application/json + parameters: + - description: form + in: body + name: form + required: true + schema: + $ref: '#/definitions/dto.PasswordResetSendSMSForm' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + $ref: '#/definitions/dto.PasswordResetSendSMSResponse' + summary: 找回密码-发送短信验证码 + tags: + - Web + /v1/auth/password/reset/verify: + post: + consumes: + - application/json + parameters: + - description: form + in: body + name: form + required: true + schema: + $ref: '#/definitions/dto.PasswordResetVerifyForm' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + $ref: '#/definitions/dto.PasswordResetVerifyResponse' + summary: 找回密码-校验验证码 + tags: + - Web /v1/auth/register: post: consumes: diff --git a/frontend/portal/src/App.vue b/frontend/portal/src/App.vue index f2f1b9f..d20e4ad 100644 --- a/frontend/portal/src/App.vue +++ b/frontend/portal/src/App.vue @@ -2,6 +2,7 @@ diff --git a/frontend/portal/src/layout/AppLayout.vue b/frontend/portal/src/layout/AppLayout.vue index 2e1dce7..ec11164 100644 --- a/frontend/portal/src/layout/AppLayout.vue +++ b/frontend/portal/src/layout/AppLayout.vue @@ -67,5 +67,4 @@ function isOutsideClicked(event) {
- diff --git a/frontend/portal/src/router/index.js b/frontend/portal/src/router/index.js index bf3bafa..891fad5 100644 --- a/frontend/portal/src/router/index.js +++ b/frontend/portal/src/router/index.js @@ -63,7 +63,7 @@ const router = createRouter({ { path: '/auth/login', name: 'login', component: () => import('@/views/pages/auth/Login.vue'), meta: { title: '登录' } }, { path: '/auth/register', name: 'register', component: () => import('@/views/pages/auth/Register.vue'), meta: { title: '注册' } }, - { path: '/auth/forgot-password', name: 'forgotPassword', component: TitlePage, meta: { title: '忘记密码' } }, + { path: '/auth/forgot-password', name: 'forgotPassword', component: () => import('@/views/pages/auth/ForgotPassword.vue'), meta: { title: '忘记密码' } }, { path: '/auth/verify', name: 'verify', component: TitlePage, meta: { title: '验证(邮箱/手机)' } }, { path: '/404', name: 'notFound', component: NotFoundPage, meta: { title: '404' } }, diff --git a/frontend/portal/src/service/auth.js b/frontend/portal/src/service/auth.js index baca259..8270fa8 100644 --- a/frontend/portal/src/service/auth.js +++ b/frontend/portal/src/service/auth.js @@ -20,3 +20,24 @@ export async function register({ username, password, confirmPassword, verifyCode if (token) await setTokenAndLoadMe(token); return token; } + +export async function sendPasswordResetSms({ phone }) { + return await requestJson('/v1/auth/password/reset/sms', { + method: 'POST', + body: { phone } + }); +} + +export async function verifyPasswordResetSms({ phone, code }) { + return await requestJson('/v1/auth/password/reset/verify', { + method: 'POST', + body: { phone, code } + }); +} + +export async function resetPassword({ resetToken, password, confirmPassword }) { + return await requestJson('/v1/auth/password/reset', { + method: 'POST', + body: { resetToken, password, confirmPassword } + }); +} diff --git a/frontend/portal/src/views/pages/auth/ForgotPassword.vue b/frontend/portal/src/views/pages/auth/ForgotPassword.vue new file mode 100644 index 0000000..33b5dd3 --- /dev/null +++ b/frontend/portal/src/views/pages/auth/ForgotPassword.vue @@ -0,0 +1,237 @@ + + +