feat: portal auth login and password reset

This commit is contained in:
2025-12-25 09:58:34 +08:00
parent 48db4a045c
commit 0c7d4ef0ea
13 changed files with 989 additions and 5 deletions

View File

@@ -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 用户注册

View File

@@ -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"`
}

View File

@@ -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,

View File

@@ -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

View File

@@ -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)

View File

@@ -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": {

View File

@@ -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": {

View File

@@ -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: