package auth import ( "context" "errors" "fmt" "time" "perms-system-server/internal/consts" "perms-system-server/internal/loaders" "perms-system-server/internal/middleware" userModel "perms-system-server/internal/model/user" "perms-system-server/internal/response" "perms-system-server/internal/svc" "perms-system-server/internal/types" "perms-system-server/internal/util" "github.com/zeromicro/go-zero/core/limit" "github.com/zeromicro/go-zero/core/logx" "golang.org/x/crypto/bcrypt" ) type ChangePasswordLogic struct { logx.Logger ctx context.Context svcCtx *svc.ServiceContext } func NewChangePasswordLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ChangePasswordLogic { return &ChangePasswordLogic{ Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx, } } // ChangePassword 修改密码。已登录用户验证原密码后设置新密码,递增 tokenVersion 并签发新令牌对,无需重新登录。 func (l *ChangePasswordLogic) ChangePassword(req *types.ChangePasswordReq) (*types.LoginResp, error) { if msg := util.ValidatePassword(req.NewPassword); msg != "" { return nil, response.ErrBadRequest(msg) } userId := middleware.GetUserId(l.ctx) if l.svcCtx.TokenOpLimiter != nil { code, _ := l.svcCtx.TokenOpLimiter.Take(fmt.Sprintf("chpwd:%d", userId)) if code == limit.OverQuota { return nil, response.ErrTooManyRequests("操作过于频繁,请稍后再试") } } user, err := l.svcCtx.SysUserModel.FindOne(l.ctx, userId) if err != nil { return nil, response.ErrNotFound("用户不存在") } if user.Status != consts.StatusEnabled { return nil, response.ErrForbidden("账号已被冻结") } // 审计 L-R17-4:先做字符串等值比较(纳秒级),后做 bcrypt.CompareHashAndPassword // (~60ms CPU)。对合法请求无语义差异;"old == new" 的非法请求可以直接 400,省掉一次 // bcrypt。Timing 差异(猜中 old=new 比猜错快 ~60ms)对攻击者几乎无用——能触发这条分支 // 的前提是攻击者已知正确的 oldPassword,此时 ChangePassword 本身已是 game over。 if req.OldPassword == req.NewPassword { return nil, response.ErrBadRequest("新密码不能与原密码相同") } if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(req.OldPassword)); err != nil { logx.WithContext(l.ctx).Infof("change-password old-password mismatch userId=%d", userId) return nil, response.ErrBadRequest("原密码错误") } hashed, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), bcrypt.DefaultCost) if err != nil { return nil, err } // 审计 H-R11-1:把上面已经读到的 user.UpdateTime / user.Username 作为乐观锁 expected 透传; // UpdatePassword 内部不再 FindOne 自对齐,CAS 的 expected 与"外层校验旧密码所依赖的那一份快照" // 严格绑定——任何并发 UpdatePassword / UpdateProfile / UpdateStatus 都会让 DB 的 updateTime // 变化,WHERE 不再命中,ErrUpdateConflict 上抛 409,迫使会话刷新后重试。 if err := l.svcCtx.SysUserModel.UpdatePassword(l.ctx, userId, user.Username, string(hashed), consts.MustChangePasswordNo, user.UpdateTime); err != nil { // 审计 M-R10-4:与 UpdateUserLogic / UpdateRoleLogic / UpdateUserStatusLogic 口径对齐, // 把乐观锁失败显式映射成 409,避免 raw error 被 rest 框架兜成 500、前端错把"并发冲突" // 当作系统故障处理,告警看板也不会把这类事件归到 5xx 噪声池。 if errors.Is(err, userModel.ErrUpdateConflict) { return nil, response.ErrConflict("密码已被其他会话修改,请刷新后重试") } return nil, err } // UpdatePassword 已将 tokenVersion 递增;新 tokenVersion = user.TokenVersion + 1。 // 先试签(签名失败不影响 DB 状态),再清缓存(detach ctx 保证独立于请求生命周期)。 newTokenVersion := user.TokenVersion + 1 ud := middleware.GetUserDetails(l.ctx) productCode := "" memberType := "" if ud != nil { productCode = ud.ProductCode memberType = ud.MemberType } accessToken, err := GenerateAccessToken( l.svcCtx.Config.Auth.AccessSecret, l.svcCtx.Config.Auth.AccessExpire, userId, user.Username, productCode, memberType, newTokenVersion, ) if err != nil { return nil, err } refreshToken, err := GenerateRefreshToken( l.svcCtx.Config.Auth.RefreshSecret, l.svcCtx.Config.Auth.RefreshExpire, userId, productCode, newTokenVersion, ) if err != nil { return nil, err } // 审计 L-R13-5 方案 A:密码变更会同步递增 tokenVersion 使旧令牌失效;UD 缓存必须立即 // 刷新,否则中间件读到的仍是旧 tokenVersion,client 可以继续用旧 token 5 分钟。detach ctx // 把这次失效从请求生命周期里摘出来。 cleanCtx, cancel := loaders.DetachCacheCleanCtx(l.ctx) defer cancel() l.svcCtx.UserDetailsLoader.Clean(cleanCtx, userId) userInfo := types.UserInfo{ UserId: userId, Username: user.Username, MustChangePassword: consts.MustChangePasswordNo, } if ud != nil { userInfo.Nickname = ud.Nickname userInfo.Avatar = ud.Avatar userInfo.Email = ud.Email userInfo.Phone = ud.Phone userInfo.IsSuperAdmin = ud.IsSuperAdminRaw userInfo.MemberType = ud.MemberType userInfo.Perms = ud.Perms } return &types.LoginResp{ AccessToken: accessToken, RefreshToken: refreshToken, Expires: time.Now().Unix() + l.svcCtx.Config.Auth.AccessExpire, UserInfo: userInfo, }, nil }