fix: 完善管理后台密码修改逻辑
This commit is contained in:
+26
-13
@@ -10,6 +10,7 @@ import (
|
|||||||
|
|
||||||
"home-vue-go/internal/config"
|
"home-vue-go/internal/config"
|
||||||
"home-vue-go/internal/database"
|
"home-vue-go/internal/database"
|
||||||
|
"home-vue-go/internal/ent"
|
||||||
"home-vue-go/internal/ent/user"
|
"home-vue-go/internal/ent/user"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -84,11 +85,11 @@ func ChangePassword(db *database.Database) gin.HandlerFunc {
|
|||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
var req struct {
|
var req struct {
|
||||||
OldPassword string `json:"oldPassword" binding:"required"`
|
OldPassword string `json:"oldPassword" binding:"required"`
|
||||||
NewPassword string `json:"newPassword" binding:"required,min=8"`
|
NewPassword string `json:"newPassword" binding:"required"`
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "旧密码和新密码不能为空,且新密码至少8位"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "当前密码和新密码不能为空"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,19 +100,32 @@ func ChangePassword(db *database.Database) gin.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
usernameStr := username.(string)
|
usernameStr, ok := username.(string)
|
||||||
|
if !ok || strings.TrimSpace(usernameStr) == "" {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "用户信息无效"})
|
||||||
|
return
|
||||||
|
}
|
||||||
ctx := c.Request.Context()
|
ctx := c.Request.Context()
|
||||||
|
|
||||||
// 查询用户
|
// 查询用户
|
||||||
user, err := db.Client.User.Query().Where(user.UsernameEQ(usernameStr)).First(ctx)
|
user, err := db.Client.User.Query().Where(user.UsernameEQ(usernameStr)).First(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "用户不存在"})
|
if ent.IsNotFound(err) {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "用户不存在"})
|
||||||
|
} else {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "查询用户失败"})
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 验证旧密码
|
// 验证旧密码
|
||||||
if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(req.OldPassword)); err != nil {
|
if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(req.OldPassword)); err != nil {
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "旧密码错误"})
|
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "当前密码错误"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := validateNewPassword(usernameStr, req.OldPassword, req.NewPassword); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,15 +137,14 @@ func ChangePassword(db *database.Database) gin.HandlerFunc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 更新密码到数据库
|
// 更新密码到数据库
|
||||||
updatedUser, err := db.Client.User.UpdateOneID(user.ID).SetPassword(string(hashedPassword)).Save(ctx)
|
updatedUser, err := db.Client.User.UpdateOneID(user.ID).SetPassword(string(hashedPassword)).Save(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "密码更新失败: " + err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "密码更新失败"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 验证密码已保存(可选,用于调试)
|
if err := bcrypt.CompareHashAndPassword([]byte(updatedUser.Password), []byte(req.NewPassword)); err != nil {
|
||||||
if updatedUser == nil {
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "密码更新校验失败"})
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "密码更新失败: 未返回更新后的用户"})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,7 +178,7 @@ func JWTAuthMiddleware(secret string) gin.HandlerFunc {
|
|||||||
location := queryIPLocation(ip)
|
location := queryIPLocation(ip)
|
||||||
// 记录为失败的登录尝试(token失效)
|
// 记录为失败的登录尝试(token失效)
|
||||||
addLoginHistory("", ip, location, userAgent, false)
|
addLoginHistory("", ip, location, userAgent, false)
|
||||||
|
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "无效的token"})
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "无效的token"})
|
||||||
c.Abort()
|
c.Abort()
|
||||||
return
|
return
|
||||||
@@ -280,7 +293,7 @@ func GetLoginHistory(db *database.Database) gin.HandlerFunc {
|
|||||||
|
|
||||||
// 从内存获取登录历史
|
// 从内存获取登录历史
|
||||||
histories := getLoginHistoryRecords(limitInt)
|
histories := getLoginHistoryRecords(limitInt)
|
||||||
|
|
||||||
result := make([]gin.H, 0, len(histories))
|
result := make([]gin.H, 0, len(histories))
|
||||||
for _, h := range histories {
|
for _, h := range histories {
|
||||||
result = append(result, gin.H{
|
result = append(result, gin.H{
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"home-vue-go/internal/config"
|
||||||
|
"home-vue-go/internal/database"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newAuthTestServer(t *testing.T) (*gin.Engine, *database.Database, *config.Config) {
|
||||||
|
t.Helper()
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
cfg := config.New(t.TempDir())
|
||||||
|
db, err := database.Init(cfg.DatabasePath, cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
r := gin.New()
|
||||||
|
r.POST("/login", Login(db, cfg))
|
||||||
|
r.PUT("/change-password", JWTAuthMiddleware(cfg.JWTSecret), ChangePassword(db))
|
||||||
|
t.Cleanup(func() { _ = db.Close() })
|
||||||
|
return r, db, cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
func authJSONRequest(t *testing.T, router http.Handler, method, path string, payload any, token string) *httptest.ResponseRecorder {
|
||||||
|
t.Helper()
|
||||||
|
body, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
req := httptest.NewRequest(method, path, strings.NewReader(string(body)))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
if token != "" {
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
}
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(response, req)
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
|
func tokenFromResponse(t *testing.T, response *httptest.ResponseRecorder) string {
|
||||||
|
t.Helper()
|
||||||
|
var payload struct {
|
||||||
|
Token string `json:"token"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(response.Body.Bytes(), &payload); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if payload.Token == "" {
|
||||||
|
t.Fatalf("login did not return a token: %s", response.Body.String())
|
||||||
|
}
|
||||||
|
return payload.Token
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChangePasswordPersistsAndAllowsNewLogin(t *testing.T) {
|
||||||
|
router, _, _ := newAuthTestServer(t)
|
||||||
|
login := authJSONRequest(t, router, http.MethodPost, "/login", map[string]string{"username": "admin", "password": "admin123"}, "")
|
||||||
|
if login.Code != http.StatusOK {
|
||||||
|
t.Fatalf("initial login failed: %d %s", login.Code, login.Body.String())
|
||||||
|
}
|
||||||
|
token := tokenFromResponse(t, login)
|
||||||
|
|
||||||
|
change := authJSONRequest(t, router, http.MethodPut, "/change-password", map[string]string{
|
||||||
|
"oldPassword": "admin123",
|
||||||
|
"newPassword": "New-admin-2026!",
|
||||||
|
}, token)
|
||||||
|
if change.Code != http.StatusOK {
|
||||||
|
t.Fatalf("password change failed: %d %s", change.Code, change.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
oldLogin := authJSONRequest(t, router, http.MethodPost, "/login", map[string]string{"username": "admin", "password": "admin123"}, "")
|
||||||
|
if oldLogin.Code != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("old password should be rejected: %d", oldLogin.Code)
|
||||||
|
}
|
||||||
|
newLogin := authJSONRequest(t, router, http.MethodPost, "/login", map[string]string{"username": "admin", "password": "New-admin-2026!"}, "")
|
||||||
|
if newLogin.Code != http.StatusOK {
|
||||||
|
t.Fatalf("new password should work: %d %s", newLogin.Code, newLogin.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChangePasswordRejectsInvalidInputWithoutLoggingOut(t *testing.T) {
|
||||||
|
router, _, _ := newAuthTestServer(t)
|
||||||
|
login := authJSONRequest(t, router, http.MethodPost, "/login", map[string]string{"username": "admin", "password": "admin123"}, "")
|
||||||
|
token := tokenFromResponse(t, login)
|
||||||
|
|
||||||
|
weak := authJSONRequest(t, router, http.MethodPut, "/change-password", map[string]string{
|
||||||
|
"oldPassword": "admin123",
|
||||||
|
"newPassword": "12345678",
|
||||||
|
}, token)
|
||||||
|
if weak.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("weak password should be rejected: %d", weak.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
wrongOld := authJSONRequest(t, router, http.MethodPut, "/change-password", map[string]string{
|
||||||
|
"oldPassword": "wrong-password",
|
||||||
|
"newPassword": "New-admin-2026!",
|
||||||
|
}, token)
|
||||||
|
if wrongOld.Code != http.StatusUnprocessableEntity {
|
||||||
|
t.Fatalf("wrong current password should be a validation error: %d", wrongOld.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
stillValid := authJSONRequest(t, router, http.MethodPut, "/change-password", map[string]string{
|
||||||
|
"oldPassword": "admin123",
|
||||||
|
"newPassword": "New-admin-2026!",
|
||||||
|
}, token)
|
||||||
|
if stillValid.Code != http.StatusOK {
|
||||||
|
t.Fatalf("valid token should remain usable after a rejected attempt: %d %s", stillValid.Code, stillValid.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateNewPassword(t *testing.T) {
|
||||||
|
if err := validateNewPassword("admin", "admin123", "New-admin-2026!"); err != nil {
|
||||||
|
t.Fatalf("expected valid password: %v", err)
|
||||||
|
}
|
||||||
|
for _, password := range []string{"short1!", "admin123", "12345678", "lettersonly", "New-admin-2026! "} {
|
||||||
|
if err := validateNewPassword("admin", "admin123", password); err == nil {
|
||||||
|
t.Errorf("expected password to be rejected: %q", password)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := validateNewPassword("admin", "admin123", strings.Repeat("a1!", 30)); err == nil {
|
||||||
|
t.Fatal("expected bcrypt-overlong password to be rejected")
|
||||||
|
}
|
||||||
|
if err := validateNewPassword("admin", "admin123", "New-管理-2026!"); err != nil {
|
||||||
|
t.Fatalf("expected unicode password to be valid: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"unicode"
|
||||||
|
"unicode/utf8"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
minPasswordRunes = 8
|
||||||
|
maxPasswordBytes = 72 // bcrypt only uses the first 72 bytes.
|
||||||
|
)
|
||||||
|
|
||||||
|
var commonPasswords = map[string]struct{}{
|
||||||
|
"12345678": {},
|
||||||
|
"admin123": {},
|
||||||
|
"password": {},
|
||||||
|
"password123": {},
|
||||||
|
"qwerty123": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateNewPassword(username, oldPassword, newPassword string) error {
|
||||||
|
if !utf8.ValidString(newPassword) {
|
||||||
|
return fmt.Errorf("新密码包含无效字符")
|
||||||
|
}
|
||||||
|
if newPassword == oldPassword {
|
||||||
|
return fmt.Errorf("新密码不能与当前密码相同")
|
||||||
|
}
|
||||||
|
if utf8.RuneCountInString(newPassword) < minPasswordRunes {
|
||||||
|
return fmt.Errorf("新密码至少需要%d个字符", minPasswordRunes)
|
||||||
|
}
|
||||||
|
if len([]byte(newPassword)) > maxPasswordBytes {
|
||||||
|
return fmt.Errorf("新密码不能超过%d字节", maxPasswordBytes)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(newPassword) != newPassword {
|
||||||
|
return fmt.Errorf("新密码不能以空格开头或结尾")
|
||||||
|
}
|
||||||
|
for _, char := range newPassword {
|
||||||
|
if unicode.IsControl(char) {
|
||||||
|
return fmt.Errorf("新密码不能包含控制字符")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, exists := commonPasswords[strings.ToLower(newPassword)]; exists {
|
||||||
|
return fmt.Errorf("新密码过于常见,请使用更复杂的密码")
|
||||||
|
}
|
||||||
|
if username != "" && strings.EqualFold(newPassword, username) {
|
||||||
|
return fmt.Errorf("新密码不能与用户名相同")
|
||||||
|
}
|
||||||
|
|
||||||
|
categoryCount := 0
|
||||||
|
hasLetter, hasNumber, hasSymbol := false, false, false
|
||||||
|
for _, char := range newPassword {
|
||||||
|
switch {
|
||||||
|
case unicode.IsLetter(char):
|
||||||
|
hasLetter = true
|
||||||
|
case unicode.IsNumber(char):
|
||||||
|
hasNumber = true
|
||||||
|
case unicode.IsPunct(char) || unicode.IsSymbol(char):
|
||||||
|
hasSymbol = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, present := range []bool{hasLetter, hasNumber, hasSymbol} {
|
||||||
|
if present {
|
||||||
|
categoryCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if categoryCount < 2 {
|
||||||
|
return fmt.Errorf("新密码至少需要包含字母、数字、符号中的两类")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -138,12 +138,13 @@
|
|||||||
<IconPicker v-model="iconPickerValue" @close="iconPickerOpen = false" />
|
<IconPicker v-model="iconPickerValue" @close="iconPickerOpen = false" />
|
||||||
</AdminModal>
|
</AdminModal>
|
||||||
|
|
||||||
<AdminModal :open="passwordModalOpen" title="修改密码" description="新密码至少八位,建议混合大小写、数字和符号" size="small" @close="closePasswordModal">
|
<AdminModal :open="passwordModalOpen" title="修改密码" description="新密码至少八个字符,并包含字母、数字、符号中的至少两类" size="small" @close="closePasswordModal">
|
||||||
<form id="password-form" class="password-form" @submit.prevent="changePassword">
|
<form id="password-form" class="password-form" @submit.prevent="changePassword">
|
||||||
<label><span>当前密码</span><div class="password-input"><input v-model="passwordForm.oldPassword" :type="passwordVisibility.old ? 'text' : 'password'" autocomplete="current-password" required /><button type="button" :aria-label="passwordVisibility.old ? '隐藏密码' : '显示密码'" @click="passwordVisibility.old = !passwordVisibility.old"><i :class="passwordVisibility.old ? 'fas fa-eye-slash' : 'fas fa-eye'"></i></button></div></label>
|
<label><span>当前密码</span><div class="password-input"><input v-model="passwordForm.oldPassword" :type="passwordVisibility.old ? 'text' : 'password'" autocomplete="current-password" required /><button type="button" :aria-label="passwordVisibility.old ? '隐藏密码' : '显示密码'" @click="passwordVisibility.old = !passwordVisibility.old"><i :class="passwordVisibility.old ? 'fas fa-eye-slash' : 'fas fa-eye'"></i></button></div></label>
|
||||||
<label><span>新密码</span><div class="password-input"><input v-model="passwordForm.newPassword" :type="passwordVisibility.new ? 'text' : 'password'" autocomplete="new-password" minlength="8" required /><button type="button" :aria-label="passwordVisibility.new ? '隐藏密码' : '显示密码'" @click="passwordVisibility.new = !passwordVisibility.new"><i :class="passwordVisibility.new ? 'fas fa-eye-slash' : 'fas fa-eye'"></i></button></div></label>
|
<label><span>新密码</span><div class="password-input"><input v-model="passwordForm.newPassword" :type="passwordVisibility.new ? 'text' : 'password'" autocomplete="new-password" minlength="8" maxlength="72" required /><button type="button" :aria-label="passwordVisibility.new ? '隐藏密码' : '显示密码'" @click="passwordVisibility.new = !passwordVisibility.new"><i :class="passwordVisibility.new ? 'fas fa-eye-slash' : 'fas fa-eye'"></i></button></div></label>
|
||||||
<div class="password-strength"><span :style="{ width: `${passwordStrength.percent}%` }" :class="passwordStrength.level"></span></div>
|
<div class="password-strength"><span :style="{ width: `${passwordStrength.percent}%` }" :class="passwordStrength.level"></span></div>
|
||||||
<small>{{ passwordStrength.label }}</small>
|
<small>{{ passwordStrength.label }}</small>
|
||||||
|
<p v-if="passwordError" class="field-error">{{ passwordError }}</p>
|
||||||
<label><span>确认新密码</span><div class="password-input"><input v-model="passwordForm.confirmPassword" :type="passwordVisibility.confirm ? 'text' : 'password'" autocomplete="new-password" required /><button type="button" :aria-label="passwordVisibility.confirm ? '隐藏密码' : '显示密码'" @click="passwordVisibility.confirm = !passwordVisibility.confirm"><i :class="passwordVisibility.confirm ? 'fas fa-eye-slash' : 'fas fa-eye'"></i></button></div></label>
|
<label><span>确认新密码</span><div class="password-input"><input v-model="passwordForm.confirmPassword" :type="passwordVisibility.confirm ? 'text' : 'password'" autocomplete="new-password" required /><button type="button" :aria-label="passwordVisibility.confirm ? '隐藏密码' : '显示密码'" @click="passwordVisibility.confirm = !passwordVisibility.confirm"><i :class="passwordVisibility.confirm ? 'fas fa-eye-slash' : 'fas fa-eye'"></i></button></div></label>
|
||||||
<p v-if="passwordMismatch" class="field-error">两次输入的新密码不一致</p>
|
<p v-if="passwordMismatch" class="field-error">两次输入的新密码不一致</p>
|
||||||
</form>
|
</form>
|
||||||
@@ -163,6 +164,7 @@ import { useRoute, useRouter } from 'vue-router'
|
|||||||
import { adminAPI } from '../api'
|
import { adminAPI } from '../api'
|
||||||
import { useTheme } from '../composables/useTheme'
|
import { useTheme } from '../composables/useTheme'
|
||||||
import { loadAndApplyFrontendConfig } from '../utils/frontendConfig'
|
import { loadAndApplyFrontendConfig } from '../utils/frontendConfig'
|
||||||
|
import { passwordMetrics } from '../utils/passwordPolicy'
|
||||||
import AppIcon from './AppIcon.vue'
|
import AppIcon from './AppIcon.vue'
|
||||||
import Dashboard from './Dashboard.vue'
|
import Dashboard from './Dashboard.vue'
|
||||||
import IconPicker from './IconPicker.vue'
|
import IconPicker from './IconPicker.vue'
|
||||||
@@ -241,20 +243,11 @@ const colorPickerValue = computed({
|
|||||||
get: () => /^#[0-9a-fA-F]{6}$/.test(contactForm.hoverColor) ? contactForm.hoverColor : '#555555',
|
get: () => /^#[0-9a-fA-F]{6}$/.test(contactForm.hoverColor) ? contactForm.hoverColor : '#555555',
|
||||||
set: (value) => { contactForm.hoverColor = value },
|
set: (value) => { contactForm.hoverColor = value },
|
||||||
})
|
})
|
||||||
|
const passwordMetricsResult = computed(() => passwordMetrics(passwordForm.newPassword, passwordForm.oldPassword))
|
||||||
const passwordMismatch = computed(() => Boolean(passwordForm.newPassword && passwordForm.confirmPassword && passwordForm.newPassword !== passwordForm.confirmPassword))
|
const passwordMismatch = computed(() => Boolean(passwordForm.newPassword && passwordForm.confirmPassword && passwordForm.newPassword !== passwordForm.confirmPassword))
|
||||||
const canChangePassword = computed(() => passwordForm.oldPassword && passwordForm.newPassword.length >= 8 && passwordForm.confirmPassword && !passwordMismatch.value)
|
const passwordError = computed(() => passwordMetricsResult.value.error)
|
||||||
const passwordStrength = computed(() => {
|
const canChangePassword = computed(() => Boolean(passwordForm.oldPassword && passwordForm.confirmPassword && passwordMetricsResult.value.valid && !passwordMismatch.value))
|
||||||
const password = passwordForm.newPassword
|
const passwordStrength = computed(() => passwordMetricsResult.value)
|
||||||
if (!password) return { percent: 0, level: '', label: '尚未输入新密码' }
|
|
||||||
let score = password.length >= 8 ? 1 : 0
|
|
||||||
if (password.length >= 12) score++
|
|
||||||
if (/[a-z]/.test(password) && /[A-Z]/.test(password)) score++
|
|
||||||
if (/\d/.test(password)) score++
|
|
||||||
if (/[^a-zA-Z0-9]/.test(password)) score++
|
|
||||||
if (score <= 2) return { percent: 34, level: 'weak', label: '密码强度:弱' }
|
|
||||||
if (score <= 4) return { percent: 68, level: 'medium', label: '密码强度:中' }
|
|
||||||
return { percent: 100, level: 'strong', label: '密码强度:强' }
|
|
||||||
})
|
|
||||||
const toastIcon = computed(() => ({ success: 'fas fa-circle-check', error: 'fas fa-circle-exclamation', warning: 'fas fa-triangle-exclamation' }[toast.type] || 'fas fa-circle-info'))
|
const toastIcon = computed(() => ({ success: 'fas fa-circle-check', error: 'fas fa-circle-exclamation', warning: 'fas fa-triangle-exclamation' }[toast.type] || 'fas fa-circle-info'))
|
||||||
const confirmTitle = computed(() => confirmAction.value?.kind === 'logout' ? '退出登录' : `删除${confirmAction.value?.kind === 'site' ? '站点' : '联系方式'}`)
|
const confirmTitle = computed(() => confirmAction.value?.kind === 'logout' ? '退出登录' : `删除${confirmAction.value?.kind === 'site' ? '站点' : '联系方式'}`)
|
||||||
const confirmDescription = computed(() => confirmAction.value?.kind === 'logout' ? '确认结束当前管理会话吗?' : `确认删除“${confirmAction.value?.item?.name || confirmAction.value?.item?.type || ''}”吗?`)
|
const confirmDescription = computed(() => confirmAction.value?.kind === 'logout' ? '确认结束当前管理会话吗?' : `确认删除“${confirmAction.value?.item?.name || confirmAction.value?.item?.type || ''}”吗?`)
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
export const PASSWORD_MIN_LENGTH = 8
|
||||||
|
export const PASSWORD_MAX_BYTES = 72
|
||||||
|
|
||||||
|
const commonPasswords = new Set(['12345678', 'admin123', 'password', 'password123', 'qwerty123'])
|
||||||
|
|
||||||
|
const byteLength = (value) => {
|
||||||
|
if (typeof TextEncoder !== 'undefined') return new TextEncoder().encode(value).length
|
||||||
|
return unescape(encodeURIComponent(value)).length
|
||||||
|
}
|
||||||
|
|
||||||
|
export const passwordMetrics = (password = '', oldPassword = '') => {
|
||||||
|
const value = String(password)
|
||||||
|
const length = Array.from(value).length
|
||||||
|
const categories = new Set()
|
||||||
|
for (const char of value) {
|
||||||
|
if (/\p{L}/u.test(char)) categories.add('letter')
|
||||||
|
else if (/\p{N}/u.test(char)) categories.add('number')
|
||||||
|
else if (/[^\p{L}\p{N}\s]/u.test(char)) categories.add('symbol')
|
||||||
|
}
|
||||||
|
|
||||||
|
const score = (length >= PASSWORD_MIN_LENGTH ? 1 : 0)
|
||||||
|
+ (length >= 12 ? 1 : 0)
|
||||||
|
+ categories.size
|
||||||
|
let strength = { percent: 0, level: '', label: '尚未输入新密码' }
|
||||||
|
if (value) {
|
||||||
|
strength = score <= 2
|
||||||
|
? { percent: 34, level: 'weak', label: '密码强度:弱' }
|
||||||
|
: score <= 4
|
||||||
|
? { percent: 68, level: 'medium', label: '密码强度:中' }
|
||||||
|
: { percent: 100, level: 'strong', label: '密码强度:强' }
|
||||||
|
}
|
||||||
|
|
||||||
|
let error = ''
|
||||||
|
if (value && length < PASSWORD_MIN_LENGTH) error = `新密码至少需要${PASSWORD_MIN_LENGTH}个字符`
|
||||||
|
else if (value && byteLength(value) > PASSWORD_MAX_BYTES) error = `新密码不能超过${PASSWORD_MAX_BYTES}字节`
|
||||||
|
else if (value && value.trim() !== value) error = '新密码不能以空格开头或结尾'
|
||||||
|
else if (value && /[\u0000-\u001f\u007f]/.test(value)) error = '新密码不能包含控制字符'
|
||||||
|
else if (value && value === oldPassword) error = '新密码不能与当前密码相同'
|
||||||
|
else if (value && commonPasswords.has(value.toLowerCase())) error = '新密码过于常见,请使用更复杂的密码'
|
||||||
|
else if (value && categories.size < 2) error = '新密码至少需要包含字母、数字、符号中的两类'
|
||||||
|
|
||||||
|
return {
|
||||||
|
length,
|
||||||
|
bytes: byteLength(value),
|
||||||
|
categoryCount: categories.size,
|
||||||
|
error,
|
||||||
|
valid: Boolean(value) && !error,
|
||||||
|
...strength,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { passwordMetrics } from './passwordPolicy'
|
||||||
|
|
||||||
|
describe('passwordPolicy', () => {
|
||||||
|
it('accepts a strong password and reports its strength', () => {
|
||||||
|
const result = passwordMetrics('New-admin-2026!', 'admin123')
|
||||||
|
expect(result.valid).toBe(true)
|
||||||
|
expect(result.level).toBe('strong')
|
||||||
|
expect(result.categoryCount).toBe(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects weak, repeated, common and padded passwords', () => {
|
||||||
|
expect(passwordMetrics('short1!', 'admin123').valid).toBe(false)
|
||||||
|
expect(passwordMetrics('admin123', 'admin123').error).toContain('相同')
|
||||||
|
expect(passwordMetrics('12345678', 'admin123').error).toContain('常见')
|
||||||
|
expect(passwordMetrics('New-admin-2026! ', 'admin123').error).toContain('空格')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('limits bcrypt-compatible UTF-8 byte length', () => {
|
||||||
|
const result = passwordMetrics(`${'管理'.repeat(36)}1!`, 'admin123')
|
||||||
|
expect(result.bytes).toBeGreaterThan(72)
|
||||||
|
expect(result.error).toContain('72')
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user