feat: 密码强度多级校验 — low/medium/high/very_high

- site_settings 新增 password.strength 配置项
- 四级:low(len>=8) / medium(两类) / high(三类+无连续) / very_high(四类+无连续)
- 连续字符检查:重复/升序/降序/键盘水平/键盘垂直序列
- 注册和改密接口统一读取配置
- 前端 register.js 动态校验 + 管理后台安全设置区
This commit is contained in:
2026-06-22 00:54:21 +08:00
parent e9b7401dc9
commit 9a496d3055
8 changed files with 225 additions and 11 deletions

View File

@ -166,6 +166,16 @@ func (s *SiteSettings) ForceGuidelines() bool {
return s.GetBool("registration.force_guidelines", true)
}
// PasswordStrength 密码强度级别
func (s *SiteSettings) PasswordStrength() string {
level := s.Get("password.strength", "medium")
switch level {
case "low", "medium", "high", "very_high":
return level
}
return "medium"
}
// GuidelinesTimer 强制阅读倒计时秒数
func (s *SiteSettings) GuidelinesTimer() int {
val := s.Get("registration.guidelines_timer", "60")

View File

@ -57,6 +57,7 @@ func (ac *AuthController) RegisterPage(c *gin.Context) {
"ExtraCSS": "/static/css/auth.css",
"Guidelines": guidelines,
"RegistrationEnabled": ac.authService.IsRegistrationEnabled(),
"PasswordStrength": ac.siteSettings.PasswordStrength(),
"ShowGuidelinesModal": ac.siteSettings.ShowGuidelinesModal(),
"ForceGuidelines": ac.siteSettings.ForceGuidelines(),
"GuidelinesTimer": ac.siteSettings.GuidelinesTimer(),

View File

@ -2,7 +2,9 @@ package service
import (
"regexp"
"strings"
"time"
"unicode"
"metazone.cc/mce/internal/common"
"metazone.cc/mce/internal/model"
@ -14,6 +16,19 @@ import (
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
@ -26,14 +41,156 @@ func init() {
dummyHash = hash
}
// validatePassword 密码强度:至少 8 位 + 包含大写字母 + 包含小写字母 + 包含数字
func validatePassword(pw string) error {
if len(pw) < 8 || !pwLower.MatchString(pw) || !pwUpper.MatchString(pw) || !pwDigit.MatchString(pw) {
// 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 {
@ -46,7 +203,7 @@ func (s *AuthService) ChangePassword(userID uint, currentPassword, newPassword s
return common.ErrIncorrectPassword
}
if err := validatePassword(newPassword); err != nil {
if err := validatePassword(newPassword, s.siteSettings.PasswordStrength()); err != nil {
return err
}

View File

@ -48,7 +48,7 @@ func (s *AuthService) Register(req model.RegisterRequest, regIP string) (*model.
}
// 2. 密码强度
if err := validatePassword(req.Password); err != nil {
if err := validatePassword(req.Password, s.siteSettings.PasswordStrength()); err != nil {
return nil, err
}

View File

@ -23,7 +23,7 @@
</div>
<div class="form-group">
<label for="password">密码</label>
<input type="password" id="password" placeholder="至少 8 位,包含大小写字母与数字" required minlength="8">
<input type="password" id="password" placeholder="至少 8 位,包含大小写字母与数字" required minlength="8" data-pw-level="{{.PasswordStrength}}">
<span class="form-error" id="passwordError">密码至少 8 位,需包含大小写字母与数字</span>
</div>
<div class="form-group">

View File

@ -23,6 +23,10 @@
var toast = document.getElementById('toast');
var emailInput = document.getElementById('email');
var passwordInput = document.getElementById('password');
// 密码强度提示从 data 属性读取
var pwHint = passwordInput.getAttribute('placeholder') || '密码至少 8 位';
var pwMinLen = 8;
var pwLevel = passwordInput.getAttribute('data-pw-level') || 'medium';
var confirmPasswordInput = document.getElementById('confirmPassword');
var rememberMeCheckbox = document.getElementById('rememberMe');
var guidelinesAgreedCheckbox = document.getElementById('guidelinesAgreed');
@ -178,7 +182,7 @@
var password = passwordInput.value;
var confirm = confirmPasswordInput.value;
var emailValid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
var passwordValid = password.length >= 8 && /[a-z]/.test(password) && /[A-Z]/.test(password) && /\d/.test(password);
var passwordValid = checkPWStrength(password);
var confirmValid = passwordValid && confirm === password;
submitBtn.disabled = !(emailValid && passwordValid && confirmValid);
}
@ -195,9 +199,22 @@
checkFormValidity();
});
function checkPWStrength(pw) {
if (pw.length < pwMinLen) return false;
var cats = 0;
if (/[a-z]/.test(pw)) cats++;
if (/[A-Z]/.test(pw)) cats++;
if (/\d/.test(pw)) cats++;
if (/[^a-zA-Z0-9]/.test(pw)) cats++;
if (pwLevel === 'low') return true;
if (pwLevel === 'medium') return cats >= 2;
if (pwLevel === 'high' || pwLevel === 'very_high') return cats >= (pwLevel === 'very_high' ? 4 : 3);
return cats >= 2;
}
passwordInput.addEventListener('input', function () {
var v = passwordInput.value;
if (v && (v.length < 8 || !/[a-z]/.test(v) || !/[A-Z]/.test(v) || !/\d/.test(v))) {
if (v && !checkPWStrength(v)) {
passwordInput.classList.add('error');
passwordError.style.display = 'block';
} else {

View File

@ -165,6 +165,28 @@
</div>
</div>
<!-- 安全设置 -->
<div class="settings-section">
<div class="section-title">安全设置</div>
<div class="section-body">
<div class="setting-item">
<div class="setting-info">
<span class="setting-label">密码强度</span>
<span class="setting-desc">注册和修改密码时的最低强度要求</span>
</div>
<div class="setting-input-wrap">
<select id="setting-password-strength" class="setting-select" data-key="password.strength">
<option value="low">低 — 仅要求 8 位</option>
<option value="medium" selected>中 — 字母+数字/符号(两类)</option>
<option value="high">高 — 大小写+数字+符号(至少三类)+ 禁止连续字符</option>
<option value="very_high">极高 — 四类全有 + 禁止连续字符</option>
</select>
<button class="btn btn-sm btn-primary btn-save-select" data-key="password.strength" data-input="setting-password-strength">保存</button>
</div>
</div>
</div>
</div>
<!-- 注册设置 -->
<div class="settings-section">
<div class="section-title">注册设置</div>

View File

@ -9,6 +9,8 @@
const TOGGLE_KEYS = [
{ id: 'maintenance-enabled', key: 'maintenance.enabled' },
{ id: 'registration-enabled', key: 'registration.enabled' },
{ id: 'registration-show-guidelines', key: 'registration.show_guidelines' },
{ id: 'registration-force-guidelines', key: 'registration.force_guidelines' },
{ id: 'audit-enabled', key: 'audit.enabled' },
{ id: 'audit-username', key: 'audit.username_audit' },
{ id: 'audit-avatar', key: 'audit.avatar_audit' },
@ -18,9 +20,15 @@
// 已有专属 UI 的设置 key不在"其他设置"中重复显示
var DEDICATED_KEYS = {
'site.framework': true, 'site.copyright_start': true,
'site.name': true, 'site.tagline': true, 'site.contact_email': true,
'site.operator_name': true, 'site.icp_number': true, 'site.icp_license': true,
'site.police_number': true, 'site.wenwangwen': true, 'site.algorithm_filing': true,
'site.privacy_url': true, 'site.terms_url': true,
'site.guidelines': true,
'maintenance.enabled': true,
'registration.enabled': true,
'registration.enabled': true, 'registration.show_guidelines': true,
'registration.force_guidelines': true, 'registration.guidelines_timer': true,
'password.strength': true,
'audit.enabled': true, 'audit.username_audit': true, 'audit.avatar_audit': true, 'audit.bio_audit': true
};
@ -73,8 +81,7 @@
// --- 文本输入保存 ---
function bindSaveButtons() {
var buttons = document.querySelectorAll('.btn-save');
buttons.forEach(function(btn) {
document.querySelectorAll('.btn-save, .btn-save-select').forEach(function(btn) {
btn.addEventListener('click', function() {
var key = btn.getAttribute('data-key');
var inputId = btn.getAttribute('data-input');