From 36aa32dc94924f21ad09398e3e8f5ba1fcc8a660 Mon Sep 17 00:00:00 2001 From: TsMask <340112800@qq.com> Date: Mon, 31 Mar 2025 15:18:17 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AF=86=E7=A0=81=E5=BC=BA=E5=BA=A6?= =?UTF-8?q?=E6=A0=A1=E9=AA=8C/=E5=AF=86=E7=A0=81=E8=BF=87=E6=9C=9F?= =?UTF-8?q?=E6=97=B6=E9=97=B4=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/modules/auth/controller/account.go | 27 ++++++- src/modules/auth/controller/bootloader.go | 13 +++- src/modules/auth/controller/register.go | 12 ++- src/modules/auth/service/account.go | 17 +++++ src/modules/auth/service/register.go | 45 +++++++++++ src/modules/system/controller/sys_profile.go | 66 ++++++++++++++++- src/modules/system/controller/sys_user.go | 28 +++++-- src/modules/system/model/sys_user.go | 38 +++++----- src/modules/system/repository/sys_user.go | 2 + src/modules/system/service/sys_user.go | 78 ++++++++++++++++++++ src/modules/system/system.go | 7 +- 11 files changed, 293 insertions(+), 40 deletions(-) diff --git a/src/modules/auth/controller/account.go b/src/modules/auth/controller/account.go index a89bf6af..b8e1989d 100644 --- a/src/modules/auth/controller/account.go +++ b/src/modules/auth/controller/account.go @@ -75,6 +75,13 @@ func (s AccountController) Login(c *gin.Context) { return } + // 强制改密码 + forcePasswdChange, err := s.accountService.PasswordExpireTime(loginUser.User.LoginCount, loginUser.User.PasswordUpdateTime) + if err != nil { + c.JSON(200, resp.ErrMsg(i18n.TKey(language, err.Error()))) + return + } + // 生成令牌,创建系统访问记录 tokenStr := token.Create(&loginUser, [4]string{ipaddr, location, os, browser}) if tokenStr == "" { @@ -89,12 +96,16 @@ func (s AccountController) Login(c *gin.Context) { ) } - c.JSON(200, resp.OkData(map[string]any{ + data := map[string]any{ "accessToken": tokenStr, "tokenType": strings.TrimRight(constants.HEADER_PREFIX, " "), "expiresIn": (loginUser.ExpireTime - loginUser.LoginTime) / 1000, "userId": loginUser.UserId, - })) + } + if forcePasswdChange { + data["forcePasswdChange"] = true + } + c.JSON(200, resp.OkData(data)) } // Me 登录用户信息 @@ -127,11 +138,19 @@ func (s AccountController) Me(c *gin.Context) { for ri := range info.User.Roles { info.User.Roles[ri].RoleName = i18n.TKey(language, info.User.Roles[ri].RoleName) } - c.JSON(200, resp.OkData(map[string]any{ + + data := map[string]any{ "user": info.User, "roles": roles, "permissions": perms, - })) + } + + // 强制改密码 + forcePasswdChange, _ := s.accountService.PasswordExpireTime(info.User.LoginCount, info.User.PasswordUpdateTime) + if forcePasswdChange { + data["forcePasswdChange"] = true + } + c.JSON(200, resp.OkData(data)) } // Router 登录用户路由信息 diff --git a/src/modules/auth/controller/bootloader.go b/src/modules/auth/controller/bootloader.go index f68f19ed..038740b4 100644 --- a/src/modules/auth/controller/bootloader.go +++ b/src/modules/auth/controller/bootloader.go @@ -9,7 +9,6 @@ import ( "be.ems/src/framework/resp" "be.ems/src/framework/token" "be.ems/src/framework/utils/machine" - "be.ems/src/framework/utils/regular" "be.ems/src/modules/auth/service" systemService "be.ems/src/modules/system/service" @@ -146,11 +145,17 @@ func (s *BootloaderController) Account(c *gin.Context) { return } - if !regular.ValidPassword(body.Password) { - // 登录密码至少包含大小写字母、数字、特殊符号,且不少于6位 - c.JSON(200, resp.ErrMsg(i18n.TKey(language, "user.errPasswd"))) + // 检查用户密码策略强度 + ok, errMsg := s.sysUserService.ValidatePasswordPolicy(body.Password, language) + if !ok { + c.JSON(200, resp.ErrMsg(errMsg)) return } + // if !regular.ValidPassword(body.Password) { + // // 登录密码至少包含大小写字母、数字、特殊符号,且不少于6位 + // c.JSON(200, resp.ErrMsg(i18n.TKey(language, "user.errPasswd"))) + // return + // } // 是否完成引导 launchInfo := machine.LaunchInfo diff --git a/src/modules/auth/controller/register.go b/src/modules/auth/controller/register.go index bde7b9d4..b29b9480 100644 --- a/src/modules/auth/controller/register.go +++ b/src/modules/auth/controller/register.go @@ -47,9 +47,15 @@ func (s *RegisterController) Register(c *gin.Context) { c.JSON(200, resp.ErrMsg(i18n.TKey(language, "register.errUsername"))) return } - if !regular.ValidPassword(body.Password) { - // 登录密码至少包含大小写字母、数字、特殊符号,且不少于6位 - c.JSON(200, resp.ErrMsg(i18n.TKey(language, "register.errPasswd"))) + // if !regular.ValidPassword(body.Password) { + // // 登录密码至少包含大小写字母、数字、特殊符号,且不少于6位 + // c.JSON(200, resp.ErrMsg(i18n.TKey(language, "register.errPasswd"))) + // return + // } + // 检查用户密码策略强度 + ok, errMsg := s.registerService.ValidatePasswordPolicy(body.Password, language) + if !ok { + c.JSON(200, resp.ErrMsg(errMsg)) return } if body.Password != body.ConfirmPassword { diff --git a/src/modules/auth/service/account.go b/src/modules/auth/service/account.go index 28f94a31..0ac46537 100644 --- a/src/modules/auth/service/account.go +++ b/src/modules/auth/service/account.go @@ -104,6 +104,7 @@ func (s Account) ByUsername(username, password string) (token.TokenInfo, error) func (s Account) UpdateLoginDateAndIP(tokenInfo token.TokenInfo) bool { user := s.sysUserService.FindById(tokenInfo.UserId) user.Password = "" // 密码不更新 + user.LoginCount += 1 user.LoginIp = tokenInfo.LoginIp user.LoginTime = tokenInfo.LoginTime return s.sysUserService.Update(user) > 0 @@ -144,6 +145,22 @@ func (s Account) passwordRetryCount(userName string) (string, int64, time.Durati return retryKey, retryCountInt64, time.Duration(lockTime) * time.Minute, nil } +// passwordRetryCount 密码过期时间 +func (s Account) PasswordExpireTime(loginCount, passwordUpdateTime int64) (bool, error) { + // 首次登录 + forcePasswdChange := loginCount < 1 + // 非首次登录,判断密码是否过期 + if !forcePasswdChange { + alert, err := s.sysUserService.ValidatePasswordExpireTime(passwordUpdateTime) + if err != nil { + return alert, err + } + forcePasswdChange = alert + } + + return forcePasswdChange, nil +} + // RoleAndMenuPerms 角色和菜单数据权限 func (s Account) RoleAndMenuPerms(userId int64, isSystemUser bool) ([]string, []string) { if isSystemUser { diff --git a/src/modules/auth/service/register.go b/src/modules/auth/service/register.go index 7cea853a..b7b0afd6 100644 --- a/src/modules/auth/service/register.go +++ b/src/modules/auth/service/register.go @@ -1,10 +1,13 @@ package service import ( + "encoding/json" "fmt" + "regexp" "be.ems/src/framework/constants" "be.ems/src/framework/database/redis" + "be.ems/src/framework/i18n" "be.ems/src/framework/utils/parse" systemModel "be.ems/src/modules/system/model" systemService "be.ems/src/modules/system/service" @@ -90,3 +93,45 @@ func (s Register) registerRoleInit() []int64 { func (s Register) registerPostInit() []int64 { return []int64{} } + +// ValidatePasswordPolicy 判断密码策略强度 +func (s Register) ValidatePasswordPolicy(password string, errLang string) (bool, string) { + passwordPolicyStr := s.sysConfigService.FindValueByKey("sys.user.passwordPolicy") + if passwordPolicyStr == "" { + // 未配置密码策略 + return false, i18n.TKey(errLang, "config.sys.user.passwordPolicyNot") + } + var policy struct { + MinLength int `json:"minLength"` + SpecialChars int `json:"specialChars"` + Uppercase int `json:"uppercase"` + Lowercase int `json:"lowercase"` + } + + err := json.Unmarshal([]byte(passwordPolicyStr), &policy) + if err != nil { + return false, err.Error() + } + + errMsg := i18n.TTemplate(errLang, "sys.user.passwordPolicyError", map[string]any{ + "minLength": policy.MinLength, + "specialChars": policy.SpecialChars, + "uppercase": policy.Uppercase, + "lowercase": policy.Lowercase, + }) + specialChars := len(regexp.MustCompile(`[!@#$%^&*(),.?":{}|<>]`).FindAllString(password, -1)) + if specialChars < policy.SpecialChars { + return false, errMsg + } + + uppercase := len(regexp.MustCompile(`[A-Z]`).FindAllString(password, -1)) + if uppercase < policy.Uppercase { + return false, errMsg + } + + lowercase := len(regexp.MustCompile(`[a-z]`).FindAllString(password, -1)) + if lowercase < policy.Lowercase { + return false, errMsg + } + return true, "" +} diff --git a/src/modules/system/controller/sys_profile.go b/src/modules/system/controller/sys_profile.go index 28b3c812..ce4a44bc 100644 --- a/src/modules/system/controller/sys_profile.go +++ b/src/modules/system/controller/sys_profile.go @@ -194,8 +194,8 @@ func (s *SysProfileController) UpdateProfile(c *gin.Context) { // @Security TokenAuth // @Summary Personal Reset Password // @Description Personal Reset Password -// @Router /system/user/profile/updatePwd [put] -func (s *SysProfileController) UpdatePassword(c *gin.Context) { +// @Router /system/user/profile/password [put] +func (s *SysProfileController) PasswordUpdate(c *gin.Context) { language := reqctx.AcceptLanguage(c) var body struct { OldPassword string `json:"oldPassword" binding:"required"` // 旧密码 @@ -247,3 +247,65 @@ func (s *SysProfileController) UpdatePassword(c *gin.Context) { } c.JSON(200, resp.Err(nil)) } + +// 强制重置密码 +// +// PUT /password-force +func (s *SysProfileController) PasswordForce(c *gin.Context) { + language := reqctx.AcceptLanguage(c) + var body struct { + Password string `json:"password" binding:"required"` + } + if err := c.ShouldBindBodyWithJSON(&body); err != nil { + errMsgs := fmt.Sprintf("bind err: %s", resp.FormatBindError(err)) + c.JSON(422, resp.CodeMsg(40422, errMsgs)) + return + } + + // 检查用户密码策略强度 + ok, errMsg := s.sysUserService.ValidatePasswordPolicy(body.Password, language) + if !ok { + c.JSON(200, resp.ErrMsg(errMsg)) + return + } + + // 登录用户信息 + loginUser, err := reqctx.LoginUser(c) + if err != nil { + c.JSON(401, resp.CodeMsg(401, i18n.TKey(language, err.Error()))) + return + } + + // 检查是否存在 + userInfo := s.sysUserService.FindById(loginUser.UserId) + if userInfo.UserId != loginUser.UserId { + // c.JSON(200, resp.ErrMsg("没有权限访问用户数据!")) + c.JSON(200, resp.ErrMsg(i18n.TKey(language, "user.noData"))) + return + } + + // 首次登录 + forcePasswdChange := userInfo.LoginCount <= 2 + // 非首次登录,判断密码是否过期 + if !forcePasswdChange { + alert, _ := s.sysUserService.ValidatePasswordExpireTime(userInfo.PasswordUpdateTime) + forcePasswdChange = alert + } + if !forcePasswdChange { + c.JSON(403, resp.ErrMsg("not matching the amendment")) + return + } + + userInfo.Password = body.Password + userInfo.UpdateBy = reqctx.LoginUserToUserName(c) + rows := s.sysUserService.Update(userInfo) + if rows > 0 { + // 更新缓存用户信息 + userInfo.Password = "" + // 移除令牌信息 + token.Remove(reqctx.Authorization(c)) + c.JSON(200, resp.Ok(nil)) + return + } + c.JSON(200, resp.Err(nil)) +} diff --git a/src/modules/system/controller/sys_user.go b/src/modules/system/controller/sys_user.go index 8275d13d..2fd443e3 100644 --- a/src/modules/system/controller/sys_user.go +++ b/src/modules/system/controller/sys_user.go @@ -189,13 +189,19 @@ func (s *SysUserController) Add(c *gin.Context) { c.JSON(400, resp.ErrMsg(msg)) return } - if !regular.ValidPassword(body.Password) { - // msg := fmt.Sprintf("新增用户【%s】失败,登录密码至少包含大小写字母、数字、特殊符号,且不少于6位", body.UserName) - msg := fmt.Sprintf("New user [%s] failed, the login password contains at least upper and lower case letters, numbers, special symbols, and not less than 6 bits", body.UserName) - c.JSON(400, resp.ErrMsg(msg)) + // if !regular.ValidPassword(body.Password) { + // // msg := fmt.Sprintf("新增用户【%s】失败,登录密码至少包含大小写字母、数字、特殊符号,且不少于6位", body.UserName) + // msg := fmt.Sprintf("New user [%s] failed, the login password contains at least upper and lower case letters, numbers, special symbols, and not less than 6 bits", body.UserName) + // c.JSON(400, resp.ErrMsg(msg)) + // return + // } + + // 检查用户密码策略强度 + ok, errMsg := s.sysUserService.ValidatePasswordPolicy(body.Password, language) + if !ok { + c.JSON(200, resp.ErrMsg(errMsg)) return } - // 检查用户登录账号是否唯一 uniqueUserName := s.sysUserService.CheckUniqueByUserName(body.UserName, 0) if !uniqueUserName { @@ -431,11 +437,17 @@ func (s *SysUserController) Password(c *gin.Context) { return } - if !regular.ValidPassword(body.Password) { - c.JSON(200, resp.ErrMsg("Login password contains at least upper and lower case letters, numbers, special symbols, and not less than 6 digits")) + // if !regular.ValidPassword(body.Password) { + // c.JSON(200, resp.ErrMsg("Login password contains at least upper and lower case letters, numbers, special symbols, and not less than 6 digits")) + // return + // } + + // 检查用户密码策略强度 + ok, errMsg := s.sysUserService.ValidatePasswordPolicy(body.Password, language) + if !ok { + c.JSON(200, resp.ErrMsg(errMsg)) return } - // 检查是否存在 userInfo := s.sysUserService.FindById(body.UserId) if userInfo.UserId != body.UserId { diff --git a/src/modules/system/model/sys_user.go b/src/modules/system/model/sys_user.go index d4eef3e0..e2594b0f 100644 --- a/src/modules/system/model/sys_user.go +++ b/src/modules/system/model/sys_user.go @@ -2,24 +2,26 @@ package model // SysUser 用户信息表 type SysUser struct { - UserId int64 `json:"userId" gorm:"column:user_id;primaryKey;autoIncrement"` // 用户ID - DeptId int64 `json:"deptId" gorm:"column:dept_id"` // 部门ID - UserName string `json:"userName" gorm:"column:user_name"` // 用户账号 - Email string `json:"email" gorm:"column:email"` // 用户邮箱 - Phone string `json:"phone" gorm:"column:phone"` // 手机号码 - NickName string `json:"nickName" gorm:"column:nick_name"` // 用户昵称 - Sex string `json:"sex" gorm:"column:sex"` // 用户性别(0未选择 1男 2女) - Avatar string `json:"avatar" gorm:"column:avatar"` // 头像地址 - Password string `json:"-" gorm:"column:password"` // 密码 - StatusFlag string `json:"statusFlag" gorm:"column:status_flag"` // 账号状态(0停用 1正常) - DelFlag string `json:"-" gorm:"column:del_flag"` // 删除标记(0存在 1删除) - LoginIp string `json:"loginIp" gorm:"column:login_ip"` // 最后登录IP - LoginTime int64 `json:"loginTime" gorm:"column:login_time"` // 最后登录时间 - CreateBy string `json:"createBy" gorm:"column:create_by"` // 创建者 - CreateTime int64 `json:"createTime" gorm:"column:create_time"` // 创建时间 - UpdateBy string `json:"updateBy" gorm:"column:update_by"` // 更新者 - UpdateTime int64 `json:"updateTime" gorm:"column:update_time"` // 更新时间 - Remark string `json:"remark" gorm:"column:remark"` // 备注 + UserId int64 `json:"userId" gorm:"column:user_id;primaryKey;autoIncrement"` // 用户ID + DeptId int64 `json:"deptId" gorm:"column:dept_id"` // 部门ID + UserName string `json:"userName" gorm:"column:user_name"` // 用户账号 + Email string `json:"email" gorm:"column:email"` // 用户邮箱 + Phone string `json:"phone" gorm:"column:phone"` // 手机号码 + NickName string `json:"nickName" gorm:"column:nick_name"` // 用户昵称 + Sex string `json:"sex" gorm:"column:sex"` // 用户性别(0未选择 1男 2女) + Avatar string `json:"avatar" gorm:"column:avatar"` // 头像地址 + Password string `json:"-" gorm:"column:password"` // 密码 + StatusFlag string `json:"statusFlag" gorm:"column:status_flag"` // 账号状态(0停用 1正常) + DelFlag string `json:"-" gorm:"column:del_flag"` // 删除标记(0存在 1删除) + PasswordUpdateTime int64 `json:"passwordUpdateTime" gorm:"column:password_update_time"` // 密码更新时间 + LoginCount int64 `json:"loginCount" gorm:"column:login_count"` // 登录次数 + LoginIp string `json:"loginIp" gorm:"column:login_ip"` // 最后登录IP + LoginTime int64 `json:"loginTime" gorm:"column:login_time"` // 最后登录时间 + CreateBy string `json:"createBy" gorm:"column:create_by"` // 创建者 + CreateTime int64 `json:"createTime" gorm:"column:create_time"` // 创建时间 + UpdateBy string `json:"updateBy" gorm:"column:update_by"` // 更新者 + UpdateTime int64 `json:"updateTime" gorm:"column:update_time"` // 更新时间 + Remark string `json:"remark" gorm:"column:remark"` // 备注 // ====== 非数据库字段属性 ====== diff --git a/src/modules/system/repository/sys_user.go b/src/modules/system/repository/sys_user.go index b4bcd753..987e28ae 100644 --- a/src/modules/system/repository/sys_user.go +++ b/src/modules/system/repository/sys_user.go @@ -134,6 +134,7 @@ func (r SysUser) Insert(sysUser model.SysUser) int64 { } if sysUser.Password != "" { sysUser.Password = crypto.BcryptHash(sysUser.Password) + sysUser.PasswordUpdateTime = time.Now().UnixMilli() } // 执行插入 if err := db.DB("").Create(&sysUser).Error; err != nil { @@ -153,6 +154,7 @@ func (r SysUser) Update(sysUser model.SysUser) int64 { } if sysUser.Password != "" { sysUser.Password = crypto.BcryptHash(sysUser.Password) + sysUser.PasswordUpdateTime = time.Now().UnixMilli() } tx := db.DB("").Model(&model.SysUser{}) // 构建查询条件 diff --git a/src/modules/system/service/sys_user.go b/src/modules/system/service/sys_user.go index 0cfaa855..0f923133 100644 --- a/src/modules/system/service/sys_user.go +++ b/src/modules/system/service/sys_user.go @@ -1,9 +1,13 @@ package service import ( + "encoding/json" "fmt" + "regexp" + "time" "be.ems/src/framework/constants" + "be.ems/src/framework/i18n" "be.ems/src/modules/system/model" "be.ems/src/modules/system/repository" ) @@ -242,3 +246,77 @@ func (s SysUser) FindAuthUsersPage(query map[string]string, dataScopeSQL string) } return rows, total } + +// ValidatePasswordPolicy 判断密码策略强度 +func (s SysUser) ValidatePasswordPolicy(password string, errLang string) (bool, string) { + passwordPolicyStr := s.sysConfigService.FindValueByKey("sys.user.passwordPolicy") + if passwordPolicyStr == "" { + // 未配置密码策略 + return false, i18n.TKey(errLang, "config.sys.user.passwordPolicyNot") + } + var policy struct { + MinLength int `json:"minLength"` + SpecialChars int `json:"specialChars"` + Uppercase int `json:"uppercase"` + Lowercase int `json:"lowercase"` + } + + err := json.Unmarshal([]byte(passwordPolicyStr), &policy) + if err != nil { + return false, err.Error() + } + + errMsg := i18n.TTemplate(errLang, "config.sys.user.passwordPolicyError", map[string]any{ + "minLength": policy.MinLength, + "specialChars": policy.SpecialChars, + "uppercase": policy.Uppercase, + "lowercase": policy.Lowercase, + }) + specialChars := len(regexp.MustCompile(`[!@#$%^&*(),.?":{}|<>]`).FindAllString(password, -1)) + if specialChars < policy.SpecialChars { + return false, errMsg + } + + uppercase := len(regexp.MustCompile(`[A-Z]`).FindAllString(password, -1)) + if uppercase < policy.Uppercase { + return false, errMsg + } + + lowercase := len(regexp.MustCompile(`[a-z]`).FindAllString(password, -1)) + if lowercase < policy.Lowercase { + return false, errMsg + } + return true, "" +} + +// ValidatePasswordExpireTime 密码过期时间 +func (s SysUser) ValidatePasswordExpireTime(passwordUpdateTime int64) (bool, error) { + passwdExpireStr := s.sysConfigService.FindValueByKey("sys.user.passwdExpire") + if passwdExpireStr == "" { + return false, nil + } + var expire struct { + ExpHours int `json:"expHours"` + AlertHours int `json:"alertHours"` + } + err := json.Unmarshal([]byte(passwdExpireStr), &expire) + if err != nil { + return false, err + } + if expire.ExpHours <= 0 { + return false, nil + } + + if passwordUpdateTime <= 1e9 { + return false, fmt.Errorf("login.errPasswdExpire") + } + // 计算时间差 + lastUpdateTime := time.UnixMilli(passwordUpdateTime) + now := time.Now() + remainingHour := now.Sub(lastUpdateTime).Hours() + alertFlag := remainingHour > float64(expire.AlertHours) + if remainingHour > float64(expire.ExpHours) { + return alertFlag, fmt.Errorf("login.errPasswdExpire") + } + return alertFlag, nil +} diff --git a/src/modules/system/system.go b/src/modules/system/system.go index 3803f1a9..3ec9b20b 100644 --- a/src/modules/system/system.go +++ b/src/modules/system/system.go @@ -314,7 +314,12 @@ func Setup(router *gin.Engine) { sysProfileGroup.PUT("/password", middleware.PreAuthorize(nil), middleware.OperateLog(middleware.OptionNew("log.operate.title.sysProfile", middleware.BUSINESS_TYPE_UPDATE)), - controller.NewSysProfile.UpdatePassword, + controller.NewSysProfile.PasswordUpdate, + ) + sysProfileGroup.PUT("/password-force", + middleware.PreAuthorize(nil), + middleware.OperateLog(middleware.OptionNew("log.operate.title.sysProfile", middleware.BUSINESS_TYPE_UPDATE)), + controller.NewSysProfile.PasswordForce, ) }