From 56db1cc6423ce1d42f4da9584c6dbdb0b5b93d24 Mon Sep 17 00:00:00 2001 From: admin_gitea Date: Wed, 5 Aug 2026 07:24:49 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E5=AE=8C=E5=96=84=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E5=90=8E=E5=8F=B0=E5=AF=86=E7=A0=81=E4=BF=AE=E6=94=B9=E9=80=BB?= =?UTF-8?q?=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/api/auth.go | 39 ++++++--- internal/api/auth_test.go | 132 +++++++++++++++++++++++++++++++ internal/api/password.go | 72 +++++++++++++++++ src/components/Admin.vue | 23 ++---- src/utils/passwordPolicy.js | 50 ++++++++++++ src/utils/passwordPolicy.test.js | 24 ++++++ 6 files changed, 312 insertions(+), 28 deletions(-) create mode 100644 internal/api/auth_test.go create mode 100644 internal/api/password.go create mode 100644 src/utils/passwordPolicy.js create mode 100644 src/utils/passwordPolicy.test.js diff --git a/internal/api/auth.go b/internal/api/auth.go index 274564c..02a76be 100644 --- a/internal/api/auth.go +++ b/internal/api/auth.go @@ -10,6 +10,7 @@ import ( "home-vue-go/internal/config" "home-vue-go/internal/database" + "home-vue-go/internal/ent" "home-vue-go/internal/ent/user" "github.com/gin-gonic/gin" @@ -84,11 +85,11 @@ func ChangePassword(db *database.Database) gin.HandlerFunc { return func(c *gin.Context) { var req struct { 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 { - c.JSON(http.StatusBadRequest, gin.H{"error": "旧密码和新密码不能为空,且新密码至少8位"}) + c.JSON(http.StatusBadRequest, gin.H{"error": "当前密码和新密码不能为空"}) return } @@ -99,19 +100,32 @@ func ChangePassword(db *database.Database) gin.HandlerFunc { 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() // 查询用户 user, err := db.Client.User.Query().Where(user.UsernameEQ(usernameStr)).First(ctx) 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 } // 验证旧密码 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 } @@ -123,15 +137,14 @@ func ChangePassword(db *database.Database) gin.HandlerFunc { } // 更新密码到数据库 - updatedUser, err := db.Client.User.UpdateOneID(user.ID).SetPassword(string(hashedPassword)).Save(ctx) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "密码更新失败: " + err.Error()}) + updatedUser, err := db.Client.User.UpdateOneID(user.ID).SetPassword(string(hashedPassword)).Save(ctx) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "密码更新失败"}) return } - // 验证密码已保存(可选,用于调试) - if updatedUser == nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "密码更新失败: 未返回更新后的用户"}) + if err := bcrypt.CompareHashAndPassword([]byte(updatedUser.Password), []byte(req.NewPassword)); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "密码更新校验失败"}) return } @@ -165,7 +178,7 @@ func JWTAuthMiddleware(secret string) gin.HandlerFunc { location := queryIPLocation(ip) // 记录为失败的登录尝试(token失效) addLoginHistory("", ip, location, userAgent, false) - + c.JSON(http.StatusUnauthorized, gin.H{"error": "无效的token"}) c.Abort() return @@ -280,7 +293,7 @@ func GetLoginHistory(db *database.Database) gin.HandlerFunc { // 从内存获取登录历史 histories := getLoginHistoryRecords(limitInt) - + result := make([]gin.H, 0, len(histories)) for _, h := range histories { result = append(result, gin.H{ diff --git a/internal/api/auth_test.go b/internal/api/auth_test.go new file mode 100644 index 0000000..06d864b --- /dev/null +++ b/internal/api/auth_test.go @@ -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) + } +} diff --git a/internal/api/password.go b/internal/api/password.go new file mode 100644 index 0000000..7f1a673 --- /dev/null +++ b/internal/api/password.go @@ -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 +} diff --git a/src/components/Admin.vue b/src/components/Admin.vue index b92d9cd..a1f6ee1 100644 --- a/src/components/Admin.vue +++ b/src/components/Admin.vue @@ -138,12 +138,13 @@ - +
- +
{{ passwordStrength.label }} +

{{ passwordError }}

两次输入的新密码不一致

@@ -163,6 +164,7 @@ import { useRoute, useRouter } from 'vue-router' import { adminAPI } from '../api' import { useTheme } from '../composables/useTheme' import { loadAndApplyFrontendConfig } from '../utils/frontendConfig' +import { passwordMetrics } from '../utils/passwordPolicy' import AppIcon from './AppIcon.vue' import Dashboard from './Dashboard.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', 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 canChangePassword = computed(() => passwordForm.oldPassword && passwordForm.newPassword.length >= 8 && passwordForm.confirmPassword && !passwordMismatch.value) -const passwordStrength = computed(() => { - const password = passwordForm.newPassword - 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 passwordError = computed(() => passwordMetricsResult.value.error) +const canChangePassword = computed(() => Boolean(passwordForm.oldPassword && passwordForm.confirmPassword && passwordMetricsResult.value.valid && !passwordMismatch.value)) +const passwordStrength = computed(() => passwordMetricsResult.value) 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 confirmDescription = computed(() => confirmAction.value?.kind === 'logout' ? '确认结束当前管理会话吗?' : `确认删除“${confirmAction.value?.item?.name || confirmAction.value?.item?.type || ''}”吗?`) diff --git a/src/utils/passwordPolicy.js b/src/utils/passwordPolicy.js new file mode 100644 index 0000000..0c753e5 --- /dev/null +++ b/src/utils/passwordPolicy.js @@ -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, + } +} diff --git a/src/utils/passwordPolicy.test.js b/src/utils/passwordPolicy.test.js new file mode 100644 index 0000000..c4005f5 --- /dev/null +++ b/src/utils/passwordPolicy.test.js @@ -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') + }) +})