Files
mce/internal/service/auth_password.go
Victor_Jay 9a496d3055 feat: 密码强度多级校验 — low/medium/high/very_high
- site_settings 新增 password.strength 配置项
- 四级:low(len>=8) / medium(两类) / high(三类+无连续) / very_high(四类+无连续)
- 连续字符检查:重复/升序/降序/键盘水平/键盘垂直序列
- 注册和改密接口统一读取配置
- 前端 register.js 动态校验 + 管理后台安全设置区
2026-06-22 00:54:21 +08:00

287 lines
6.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package service
import (
"regexp"
"strings"
"time"
"unicode"
"metazone.cc/mce/internal/common"
"metazone.cc/mce/internal/model"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
)
var pwLower = regexp.MustCompile(`[a-z]`)
var pwUpper = regexp.MustCompile(`[A-Z]`)
var pwDigit = regexp.MustCompile(`\d`)
var pwSymbol = regexp.MustCompile(`[^a-zA-Z0-9]`)
// 键盘水平序列QWERTY 布局)
var keyboardHoriz = []string{
"qwertyuiop", "asdfghjkl", "zxcvbnm",
"poiuytrewq", "lkjhgfdsa", "mnbvcxz",
}
// 键盘垂直序列
var keyboardVert = []string{
"qaz", "wsx", "edc", "rfv", "tgb", "yhn", "ujm", "ik", "ol", "p",
"zaq", "xsw", "cde", "vfr", "bgt", "nhy", "mju", "ki", "lo",
}
// dummyHash 防时序攻击:当用户不存在时,仍对虚拟哈希执行完整的 bcrypt 比对
var dummyHash []byte
func init() {
hash, err := bcrypt.GenerateFromPassword([]byte("metazone-dummy-hash-2026"), 12)
if err != nil {
panic("failed to generate bcrypt dummy hash: " + err.Error())
}
dummyHash = hash
}
// validatePassword 多级密码强度校验
func validatePassword(pw string, level string) error {
if level == "" {
level = "medium"
}
if len(pw) < 8 {
return common.ErrWeakPassword
}
switch level {
case "low":
return nil
case "medium":
if !hasAtLeastN(pw, 2) {
return common.ErrWeakPassword
}
case "high":
if !hasAtLeastN(pw, 3) {
return common.ErrWeakPassword
}
if isWeakSequence(pw) {
return common.ErrWeakPassword
}
case "very_high":
if !hasAtLeastN(pw, 4) {
return common.ErrWeakPassword
}
if isWeakSequence(pw) {
return common.ErrWeakPassword
}
}
return nil
}
// hasAtLeastN 检查密码包含至少 n 类字符(小写/大写/数字/符号)
func hasAtLeastN(pw string, n int) bool {
count := 0
if pwLower.MatchString(pw) {
count++
}
if pwUpper.MatchString(pw) {
count++
}
if pwDigit.MatchString(pw) {
count++
}
if pwSymbol.MatchString(pw) {
count++
}
return count >= n
}
// isWeakSequence 检查连续字符≥3 重复/排序/键盘序列)
func isWeakSequence(pw string) bool {
runes := []rune(strings.ToLower(pw))
if len(runes) < 3 {
return false
}
// 重复字符 aaa 111
repeatCount := 1
// 升序/降序计数
ascCount, descCount := 1, 1
for i := 1; i < len(runes); i++ {
// 重复
if runes[i] == runes[i-1] {
repeatCount++
if repeatCount >= 3 {
return true
}
} else {
repeatCount = 1
}
// 升序 abc / 123
if runes[i] == runes[i-1]+1 {
ascCount++
if ascCount >= 3 {
return true
}
} else {
ascCount = 1
}
// 降序 cba / 321
if runes[i] == runes[i-1]-1 {
descCount++
if descCount >= 3 {
return true
}
} else {
descCount = 1
}
}
// 键盘序列
lower := strings.ToLower(pw)
for _, seq := range keyboardHoriz {
if len(seq) >= 3 {
for i := 0; i <= len(seq)-3; i++ {
if strings.Contains(lower, seq[i:i+3]) {
return true
}
}
}
}
for _, seq := range keyboardVert {
if len(seq) >= 3 && strings.Contains(lower, seq) {
return true
}
}
return false
}
// PasswordStrengthLevel 获取当前密码强度级别
func PasswordStrengthLevel(strengthStr string) string {
switch strengthStr {
case "low", "medium", "high", "very_high":
return strengthStr
}
return "medium"
}
// PasswordStrengthHint 前端校验提示文案
func PasswordStrengthHint(level string) string {
switch level {
case "low":
return "密码至少 8 位"
case "medium":
return "密码至少 8 位,需包含字母、数字或符号中的两类"
case "high":
return "密码至少 8 位,需包含大小写字母、数字和符号中的至少三类,且不含连续字符"
case "very_high":
return "密码至少 8 位,需包含大小写字母、数字和符号全部四类,且不含连续字符"
}
return "密码至少 8 位"
}
// hasUnicode 检查是否包含非 ASCII 字符
func hasUnicode(s string) bool {
for _, r := range s {
if r > unicode.MaxASCII {
return true
}
}
return false
}
// ChangePassword 修改密码
// 流程:验证当前密码 → 校验新密码强度 → 哈希 → 更新 → 删除所有 session强制重新登录
func (s *AuthService) ChangePassword(userID uint, currentPassword, newPassword string) error {
user, err := s.userRepo.FindByID(userID)
if err != nil {
return common.ErrUserNotFound
}
if bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(currentPassword)) != nil {
return common.ErrIncorrectPassword
}
if err := validatePassword(newPassword, s.siteSettings.PasswordStrength()); err != nil {
return err
}
hash, err := common.HashPassword(newPassword, s.cfg.Bcrypt.Cost)
if err != nil {
return err
}
user.PasswordHash = hash
if err := s.userRepo.Update(user); err != nil {
return err
}
// 删除所有 session强制所有设备重新登录
return s.invalidateSessions(userID)
}
// DeleteAccount 用户自主注销
func (s *AuthService) DeleteAccount(userID uint, password, reason string) error {
user, err := s.userRepo.FindByID(userID)
if err != nil {
return common.ErrUserNotFound
}
if user.Role == model.RoleOwner {
return common.ErrOwnerCannotDelete
}
if bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)) != nil {
return common.ErrIncorrectPassword
}
user.Status = model.StatusDeleted
user.DeleteReason = reason
if err := s.userRepo.Update(user); err != nil {
return err
}
return s.invalidateSessions(userID)
}
// invalidateSessions 销毁某用户的所有 session内部方法
func (s *AuthService) invalidateSessions(userID uint) error {
return s.sessionManager.DestroyByUID(userID)
}
// InvalidateSessions 公开方法:销毁用户所有 session供 admin 等外部调用)
func (s *AuthService) InvalidateSessions(userID uint) error {
return s.sessionManager.DestroyByUID(userID)
}
// ConfirmRestore 二次确认恢复已注销账号
func (s *AuthService) ConfirmRestore(req model.LoginRequest, loginIP string) (*model.User, error) {
user, err := s.userRepo.FindByEmail(req.Email)
if err != nil {
_ = bcrypt.CompareHashAndPassword(dummyHash, []byte(req.Password))
return nil, common.ErrInvalidCred
}
if user.Status != model.StatusDeleted {
return nil, common.ErrUserNotFound
}
if bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.Password)) != nil {
return nil, common.ErrInvalidCred
}
// 恢复账号
user.Status = model.StatusActive
user.DeletedAt = gorm.DeletedAt{}
now := time.Now()
user.LastLoginIP = loginIP
user.LastLoginAt = &now
if err := s.userRepo.Update(user); err != nil {
return nil, err
}
return user, nil
}