- site_settings 新增 password.strength 配置项 - 四级:low(len>=8) / medium(两类) / high(三类+无连续) / very_high(四类+无连续) - 连续字符检查:重复/升序/降序/键盘水平/键盘垂直序列 - 注册和改密接口统一读取配置 - 前端 register.js 动态校验 + 管理后台安全设置区
287 lines
6.5 KiB
Go
287 lines
6.5 KiB
Go
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
|
||
}
|