diff --git a/.env.example b/.env.example index 2ec0749..f393d62 100644 --- a/.env.example +++ b/.env.example @@ -4,8 +4,8 @@ # PostgreSQL 连接串 DB_DSN=postgres://postgres:postgres@localhost:5432/jiang13?sslmode=disable -# JWT 签名密钥(生产环境务必修改) -JWT_SECRET=your-jwt-secret-change-me +# JWT 签名密钥(≥32 字符强随机;禁止 change-me 等占位,留空则自动 crypto/rand 生成) +# JWT_SECRET= # 服务端口 HTTP_PORT=3001 diff --git a/backend/app.ini.example b/backend/app.ini.example index 86e5656..6eb0696 100644 --- a/backend/app.ini.example +++ b/backend/app.ini.example @@ -6,7 +6,7 @@ HTTP_PORT = 3001 DSN = postgres://postgres:postgres@localhost:5432/jiang13?sslmode=disable [security] -; 留空则自动生成并持久化到 data/.jwt_secret;生产环境务必显式指定强随机值 +; 留空则用 crypto/rand 生成并持久化到 data/.jwt_secret;生产务必显式指定 ≥32 字符强随机值 JWT_SECRET = ; 32 字节随机值的标准 Base64(openssl rand -base64 32)。留空则不能保存邮件/S3 凭据。 ; 环境变量 SETTINGS_MASTER_KEY 优先于此处。丢失后已加密凭据无法解密,不要提交真实值。 diff --git a/backend/config/config.go b/backend/config/config.go index 56b5c58..9c5d3c1 100644 --- a/backend/config/config.go +++ b/backend/config/config.go @@ -1,7 +1,10 @@ package config import ( + "crypto/rand" + "encoding/base64" "fmt" + "log" "os" "path/filepath" "strconv" @@ -35,7 +38,7 @@ type appIniKey struct { var appIniSchema = []appIniKey{ {section: "server", key: "HTTP_PORT", value: "3001"}, {section: "database", key: "DSN", value: "postgres://postgres:postgres@localhost:5432/jiang13?sslmode=disable", comment: "PostgreSQL 连接串(部署时请修改账号密码)"}, - {section: "security", key: "JWT_SECRET", value: "", comment: "留空则自动生成并持久化到 data/.jwt_secret;生产环境务必显式指定强随机值"}, + {section: "security", key: "JWT_SECRET", value: "", comment: "留空则用 crypto/rand 生成并持久化到 data/.jwt_secret;生产务必显式指定 ≥32 字符强随机值,禁止 change-me 等占位"}, {section: "security", key: "SETTINGS_MASTER_KEY", value: "", comment: "32 字节随机值的标准 Base64(openssl rand -base64 32)。留空则不能保存邮件/S3 凭据。\n环境变量 SETTINGS_MASTER_KEY 优先于此处。丢失后已加密凭据无法解密,不要提交真实值。"}, {section: "paths", key: "DATA", value: "data"}, {section: "app", key: "DEV_MODE", value: "true"}, @@ -112,14 +115,28 @@ func Parse() (*Config, error) { return nil, fmt.Errorf("创建数据目录失败: %w", err) } - // JWT 密钥:留空则自动生成并持久化 - if strings.TrimSpace(cfg.JWTSecret) == "" { - secretFile := filepath.Join(cfg.DataDir, ".jwt_secret") + // JWT 密钥:显式配置优先;留空则从 data/.jwt_secret 读取或用加密随机生成 + secretFile := filepath.Join(cfg.DataDir, ".jwt_secret") + explicitSecret := strings.TrimSpace(cfg.JWTSecret) != "" + if !explicitSecret { if data, err := os.ReadFile(secretFile); err == nil && len(data) > 0 { - cfg.JWTSecret = string(data) - } else { - cfg.JWTSecret = generateSecret(32) - _ = os.WriteFile(secretFile, []byte(cfg.JWTSecret), 0600) + cfg.JWTSecret = strings.TrimSpace(string(data)) + } + } + if isWeakJWTSecret(cfg.JWTSecret) { + if explicitSecret { + return nil, fmt.Errorf("JWT_SECRET 过弱、过短或为已知占位/确定性旧值,请设置至少 32 字符的强随机密钥(例如:openssl rand -base64 32)") + } + if cfg.JWTSecret != "" { + log.Println("[config] 检测到弱/确定性 .jwt_secret,正在轮换为加密随机密钥") + } + secret, err := generateSecret(32) + if err != nil { + return nil, fmt.Errorf("生成 JWT 密钥失败: %w", err) + } + cfg.JWTSecret = secret + if err := os.WriteFile(secretFile, []byte(cfg.JWTSecret), 0600); err != nil { + return nil, fmt.Errorf("写入 .jwt_secret 失败: %w", err) } } @@ -306,11 +323,36 @@ func rejectStrayDataDir(dataDir string) error { return nil } -func generateSecret(n int) string { - const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" - b := make([]byte, n) - for i := range b { - b[i] = chars[i%len(chars)] +// generateSecret 用 crypto/rand 生成 URL-safe 密钥(约 n 字节熵)。 +func generateSecret(n int) (string, error) { + if n < 32 { + n = 32 } - return string(b) + b := make([]byte, n) + if _, err := rand.Read(b); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(b), nil +} + +// isWeakJWTSecret 拒绝过短、占位示例、以及历史上确定性 generateSecret 产物。 +func isWeakJWTSecret(s string) bool { + s = strings.TrimSpace(s) + if len(s) < 32 { + return true + } + switch strings.ToLower(s) { + case "change-me", + "your-jwt-secret-change-me", + "change-me-to-a-long-random-string", + "secret", + "jwt-secret": + return true + } + // 旧版确定性循环字符表:abcdefghijklmnopqrstuvwxyzABCDEF... + const legacyCharset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + if len(s) <= len(legacyCharset) && s == legacyCharset[:len(s)] { + return true + } + return false } diff --git a/backend/config/config_test.go b/backend/config/config_test.go index 8702c06..0782210 100644 --- a/backend/config/config_test.go +++ b/backend/config/config_test.go @@ -114,14 +114,61 @@ func TestAllowOriginDevAndProd(t *testing.T) { } } +const testJWTSecret = "unit-test-jwt-secret-32chars-min!!" // 满足 ≥32,非生产用 + +func TestIsWeakJWTSecret(t *testing.T) { + if !isWeakJWTSecret("") || !isWeakJWTSecret("short") { + t.Fatal("空/过短应判弱") + } + if !isWeakJWTSecret("abcdefghijklmnopqrstuvwxyzABCDEF") { + t.Fatal("旧确定性密钥应判弱") + } + if !isWeakJWTSecret("change-me") { + t.Fatal("占位值应判弱") + } + if isWeakJWTSecret(testJWTSecret) { + t.Fatal("测试强密钥不应判弱") + } +} + +func TestGenerateSecretNotDeterministic(t *testing.T) { + a, err := generateSecret(32) + if err != nil { + t.Fatal(err) + } + b, err := generateSecret(32) + if err != nil { + t.Fatal(err) + } + if a == b { + t.Fatal("连续两次生成不应相同") + } + if isWeakJWTSecret(a) { + t.Fatalf("生成结果被误判为弱: %q", a) + } +} + +func TestParseRejectsWeakExplicitSecret(t *testing.T) { + work := t.TempDir() + if err := os.WriteFile(filepath.Join(work, "app.ini"), []byte("[security]\nJWT_SECRET = change-me\n"), 0600); err != nil { + t.Fatal(err) + } + t.Setenv("JIANG13_WORK_PATH", work) + t.Setenv("JWT_SECRET", "") + if _, err := Parse(); err == nil { + t.Fatal("显式弱密钥应拒绝启动") + } +} + func TestParseSettingsMasterKeyFromIni(t *testing.T) { work := t.TempDir() key := "AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=" - if err := os.WriteFile(filepath.Join(work, "app.ini"), []byte("[security]\nJWT_SECRET = test-secret-not-for-prod\nSETTINGS_MASTER_KEY = "+key+"\n"), 0600); err != nil { + if err := os.WriteFile(filepath.Join(work, "app.ini"), []byte("[security]\nJWT_SECRET = "+testJWTSecret+"\nSETTINGS_MASTER_KEY = "+key+"\n"), 0600); err != nil { t.Fatal(err) } t.Setenv("JIANG13_WORK_PATH", work) t.Setenv("SETTINGS_MASTER_KEY", "") + t.Setenv("JWT_SECRET", "") cfg, err := Parse() if err != nil { t.Fatal(err) @@ -149,7 +196,7 @@ func TestParseSiteURLAndDataDir(t *testing.T) { t.Setenv("SITE_URL", "https://bbs.example.com/") t.Setenv("CORS_ORIGINS", " https://a.example.com ,https://b.example.com/ ") t.Setenv("DATA_DIR", data) - t.Setenv("JWT_SECRET", "test-secret-not-for-prod") + t.Setenv("JWT_SECRET", testJWTSecret) cfg, err := Parse() if err != nil { @@ -210,7 +257,7 @@ func TestEnsureAppIniBackfillsMissingKeys(t *testing.T) { func TestParseSiteURLFromIni(t *testing.T) { work := t.TempDir() - body := "[security]\nJWT_SECRET = test-secret-not-for-prod\n\n[app]\nDEV_MODE = true\nSITE_URL = https://forum.example.com/\nCORS_ORIGINS = https://a.example.com, https://b.example.com/\n" + body := "[security]\nJWT_SECRET = "+testJWTSecret+"\n\n[app]\nDEV_MODE = true\nSITE_URL = https://forum.example.com/\nCORS_ORIGINS = https://a.example.com, https://b.example.com/\n" if err := os.WriteFile(filepath.Join(work, "app.ini"), []byte(body), 0600); err != nil { t.Fatal(err) } diff --git a/backend/handler/auth.go b/backend/handler/auth.go index ef3ffa6..426ac6d 100644 --- a/backend/handler/auth.go +++ b/backend/handler/auth.go @@ -59,27 +59,46 @@ func setAuthCookies(c *gin.Context, accessToken, refreshToken string, secure boo }) } -// clearAuthCookies 清除当前契约下的三枚认证 cookie(名称随 ConfigureCookieNames,Path=/)。 +// clearAuthCookies 清除当前契约下的认证 cookie,并顺带清历史 Path=/api/auth 与无前缀名。 func clearAuthCookies(c *gin.Context, secure bool) { - cookies := []struct { + type ck struct { name string httpOnly bool - }{ + } + cookies := []ck{ {service.CookieName, true}, {service.RefreshCookieName, true}, {service.CSRFCookieName, false}, + // 历史无 __Host- 前缀(切换 DEV_MODE 后避免双 cookie) + {"j13_token", true}, + {"j13_refresh", true}, + {"j13_csrf", false}, + {"__Host-j13_token", true}, + {"__Host-j13_refresh", true}, + {"__Host-j13_csrf", false}, } + paths := []string{"/", "/api/auth"} for _, ck := range cookies { - http.SetCookie(c.Writer, &http.Cookie{ - Name: ck.name, - Value: "", - Path: "/", - MaxAge: -1, - HttpOnly: ck.httpOnly, - Secure: secure, - SameSite: http.SameSiteLaxMode, - Expires: time.Unix(0, 0), - }) + for _, path := range paths { + // __Host- 要求 Secure=true 且 Path=/;非 / 路径跳过 Host 前缀名 + if strings.HasPrefix(ck.name, "__Host-") && path != "/" { + continue + } + sec := secure + if strings.HasPrefix(ck.name, "__Host-") { + sec = true + } + http.SetCookie(c.Writer, &http.Cookie{ + Name: ck.name, + Value: "", + Path: path, + MaxAge: -1, + HttpOnly: ck.httpOnly, + Secure: sec, + SameSite: http.SameSiteLaxMode, + Expires: time.Unix(0, 0), + }) + } } } @@ -233,7 +252,7 @@ func (h *Handlers) Refresh(c *gin.Context) { // access JWT 无状态、15 分钟自然过期;登出后 tv 不递增,属可接受的短窗口。 func (h *Handlers) Logout(c *gin.Context) { if refreshToken, err := c.Cookie(service.RefreshCookieName); err == nil && refreshToken != "" { - h.Auth.RevokeRefreshToken(refreshToken) + _ = h.Auth.RevokeRefreshToken(refreshToken) } clearAuthCookies(c, !h.Cfg.DevMode) c.JSON(http.StatusOK, gin.H{"message": "已登出"}) diff --git a/backend/handler/health.go b/backend/handler/health.go index 6d401c6..b91e6f1 100644 --- a/backend/handler/health.go +++ b/backend/handler/health.go @@ -13,7 +13,6 @@ func (h *Handlers) Health(c *gin.Context) { if err := model.PingDB(); err != nil { c.JSON(http.StatusServiceUnavailable, gin.H{ "status": "error", - "error": err.Error(), "version": version.Version, }) return diff --git a/backend/handler/post.go b/backend/handler/post.go index 336d23b..803e858 100644 --- a/backend/handler/post.go +++ b/backend/handler/post.go @@ -538,10 +538,11 @@ func (h *Handlers) ToggleRecommend(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"recommended": rec}) } -// requireAdminOrAbove 置顶/加精仅管理员及以上可用;校验失败已写响应,返回 false +// requireAdminOrAbove 置顶/加精仅管理员及以上可用;以 DB Actor 为准 func (h *Handlers) requireAdminOrAbove(c *gin.Context) bool { claims := middleware.CurrentUser(c) - if model.RoleLevel(model.Role(claims.Role)) < model.RoleLevel(model.RoleAdmin) { + actor, err := h.Auth.LoadActor(claims.ID) + if err != nil || model.RoleLevel(actor.Role) < model.RoleLevel(model.RoleAdmin) { c.JSON(http.StatusForbidden, gin.H{"error": "仅管理员可操作"}) return false } diff --git a/backend/model/db.go b/backend/model/db.go index 70188cc..d1df38d 100644 --- a/backend/model/db.go +++ b/backend/model/db.go @@ -7,10 +7,8 @@ import ( "fmt" "log" "os" - "strings" "time" - "github.com/freefire/jiang13-bbs/markdown" "gorm.io/driver/postgres" "gorm.io/gorm" "gorm.io/gorm/logger" @@ -68,20 +66,6 @@ func InitDB(dsn string) error { return fmt.Errorf("自动迁移失败: %w", err) } - // 旧帖整帖可见性 → 正文 [hide] 块(幂等) - if err := migratePostContentAccessToHideBlocks(db); err != nil { - return fmt.Errorf("正文隐藏块迁移失败: %w", err) - } - // 旧式 :::hide / :::timeline → 行级 BBCode(幂等) - if err := migrateLegacyDirectiveSyntax(db); err != nil { - return fmt.Errorf("短代码语法迁移失败: %w", err) - } - - // 一次性:用签到累计回填 User.Points(仅余额仍为 0 且有签到积分的用户) - if err := backfillPointsFromCheckin(db); err != nil { - return fmt.Errorf("积分余额回填失败: %w", err) - } - // RBAC:把初始管理员(id 最小的 admin,通常即首个注册账号)升级为站长; // 已存在 owner 时不动数据,保证幂等 if err := ensureOwnerRole(db); err != nil { @@ -310,46 +294,6 @@ func ensureOwnerRole(db *gorm.DB) error { return nil } -// backfillPointsFromCheckin 把历史签到积分写入 User.Points(幂等:仅 points=0 且有签到) -func backfillPointsFromCheckin(db *gorm.DB) error { - type row struct { - UserID uint - Total int - } - var rows []row - if err := db.Raw(` - SELECT c.user_id, COALESCE(SUM(c.points), 0)::int AS total - FROM checkins c - INNER JOIN users u ON u.id = c.user_id AND u.deleted_at IS NULL AND u.points = 0 - GROUP BY c.user_id - HAVING COALESCE(SUM(c.points), 0) > 0 - `).Scan(&rows).Error; err != nil { - return err - } - for _, r := range rows { - err := db.Transaction(func(tx *gorm.DB) error { - if err := tx.Model(&User{}).Where("id = ? AND points = 0", r.UserID). - Update("points", r.Total).Error; err != nil { - return err - } - return tx.Create(&PointLedger{ - UserID: r.UserID, - Delta: r.Total, - Balance: r.Total, - Reason: PointReasonMigrateCheckin, - Note: "历史签到积分回填", - }).Error - }) - if err != nil { - return err - } - } - if len(rows) > 0 { - log.Printf("[model] 已回填 %d 名用户的签到积分余额", len(rows)) - } - return nil -} - // seedDefaultBoards 写入默认板块 func seedDefaultBoards(db *gorm.DB) { defaults := []Board{ @@ -455,134 +399,3 @@ func ensureDefaultChatMemberships(db *gorm.DB) error { } return nil } - -// migratePostContentAccessToHideBlocks 将旧帖整帖可见性包进 [hide] 块(幂等)。 -func migratePostContentAccessToHideBlocks(db *gorm.DB) error { - var posts []Post - if err := db.Unscoped(). - Where("content_access IN ? AND deleted_at IS NULL", - []string{ContentAccessLogin, ContentAccessReply, ContentAccessPoints}). - Find(&posts).Error; err != nil { - return err - } - n := 0 - for i := range posts { - p := &posts[i] - if markdown.HasHideBlocks(p.Content) { - continue - } - kind := NormalizeContentAccess(p.ContentAccess) - pts := 0 - if kind == ContentAccessPoints { - pts = p.AccessPoints - if pts <= 0 { - pts = 1 - } - } - wrapped := markdown.WrapContentAsHide(kind, pts, p.Content) - if err := db.Model(&Post{}).Where("id = ?", p.ID). - Update("content", wrapped).Error; err != nil { - return err - } - n++ - } - if n > 0 { - log.Printf("[model] 旧帖可见性已迁入隐藏块:%d 篇", n) - } - return nil -} - -// migrateLegacyDirectiveSyntax 将正文中的 :::hide / :::timeline 改写为 BBCode(幂等)。 -func migrateLegacyDirectiveSyntax(db *gorm.DB) error { - type row struct { - ID uint - Content string - } - rewriteTable := func(table string, rows []row) (int, error) { - n := 0 - for _, r := range rows { - if !strings.Contains(r.Content, ":::") { - continue - } - next := markdown.RewriteLegacyDirectives(r.Content) - if next == r.Content { - continue - } - if err := db.Table(table).Where("id = ?", r.ID). - Update("content", next).Error; err != nil { - return n, err - } - n++ - } - return n, nil - } - - var posts []row - if err := db.Model(&Post{}).Unscoped().Select("id", "content"). - Where("content LIKE ?", "%:::%").Find(&posts).Error; err != nil { - return err - } - if n, err := rewriteTable("posts", posts); err != nil { - return err - } else if n > 0 { - log.Printf("[model] 帖子短代码改写:%d 篇", n) - } - - var comments []row - if err := db.Model(&Comment{}).Unscoped().Select("id", "content"). - Where("content LIKE ?", "%:::%").Find(&comments).Error; err != nil { - return err - } - if n, err := rewriteTable("comments", comments); err != nil { - return err - } else if n > 0 { - log.Printf("[model] 评论短代码改写:%d 条", n) - } - - var anns []row - if err := db.Model(&Announcement{}).Unscoped().Select("id", "content"). - Where("content LIKE ?", "%:::%").Find(&anns).Error; err != nil { - return err - } - if n, err := rewriteTable("announcements", anns); err != nil { - return err - } else if n > 0 { - log.Printf("[model] 公告短代码改写:%d 条", n) - } - - var pages []row - if err := db.Model(&SitePage{}).Unscoped().Select("id", "content"). - Where("content LIKE ?", "%:::%").Find(&pages).Error; err != nil { - return err - } - if n, err := rewriteTable("site_pages", pages); err != nil { - return err - } else if n > 0 { - log.Printf("[model] 站点页短代码改写:%d 篇", n) - } - - var hist []struct { - ID uint - OldContent string - } - if err := db.Model(&CommentEditHistory{}).Select("id", "old_content"). - Where("old_content LIKE ?", "%:::%").Find(&hist).Error; err != nil { - return err - } - nHist := 0 - for _, r := range hist { - next := markdown.RewriteLegacyDirectives(r.OldContent) - if next == r.OldContent { - continue - } - if err := db.Model(&CommentEditHistory{}).Where("id = ?", r.ID). - Update("old_content", next).Error; err != nil { - return err - } - nHist++ - } - if nHist > 0 { - log.Printf("[model] 评论历史短代码改写:%d 条", nHist) - } - return nil -} diff --git a/backend/router/router.go b/backend/router/router.go index 580f2aa..9fac9ff 100644 --- a/backend/router/router.go +++ b/backend/router/router.go @@ -155,8 +155,8 @@ func Setup(cfg *config.Config) (*gin.Engine, error) { pubAPI.POST("/auth/code", middleware.CSRFMiddleware(), h.SendEmailCode) pubAPI.POST("/auth/reset-password", middleware.CSRFMiddleware(), h.ResetPassword) pubAPI.POST("/telemetry/pageview", middleware.CSRFMiddleware(), h.TelemetryPageView) - pubAPI.POST("/register", middleware.RateLimitMiddleware(limiter, service.RateRegister), h.Register) - pubAPI.POST("/login", middleware.RateLimitMiddleware(limiter, service.RateLogin), h.Login) + pubAPI.POST("/register", middleware.CSRFMiddleware(), middleware.RateLimitMiddleware(limiter, service.RateRegister), h.Register) + pubAPI.POST("/login", middleware.CSRFMiddleware(), middleware.RateLimitMiddleware(limiter, service.RateLogin), h.Login) // refresh token 端点:access 过期后用 refresh 换新 token(需 CSRF 防护) pubAPI.POST("/auth/refresh", middleware.CSRFMiddleware(), h.Refresh) // 登出不依赖有效登录态:封禁/登录态失效后前端仍需凭它清除 cookie(CSRF 仍校验) diff --git a/backend/service/auth.go b/backend/service/auth.go index a8306c7..ede7dd4 100644 --- a/backend/service/auth.go +++ b/backend/service/auth.go @@ -47,7 +47,8 @@ var ( // ConfigureCookieNames 生产环境(HTTPS)启用 __Host- 前缀: // 浏览器强制 Secure、Path=/、禁止 Domain,进一步收窄 cookie 作用面。 -// 必须在路由注册前调用一次;前端按 NODE_ENV=production 同步启用。 +// 必须在路由注册前调用一次;前端以 DEV_MODE 为准(与后端 !DevMode 对齐), +// 未设置 DEV_MODE 时回退 NODE_ENV=production。 func ConfigureCookieNames(useHostPrefix bool) { if !useHostPrefix { return @@ -84,43 +85,51 @@ func NewAuthService(db *gorm.DB, jwtSecret string) *AuthService { var dummyPasswordHash, _ = bcrypt.GenerateFromPassword( []byte("j13-dummy-password-for-constant-timing"), bcrypt.DefaultCost) -// Register 用户注册 +// Register 用户注册(事务 + 咨询锁,保证空库至多一名 owner) func (s *AuthService) Register(username, email, password string) (*model.User, error) { if err := NewOperations(s.db, nil).Filter("username", username, 0); err != nil { return nil, err } - // 检查用户名是否已存在 - var count int64 - s.db.Model(&model.User{}).Where("username = ?", username).Count(&count) - if count > 0 { - return nil, errors.New("用户名已被使用") - } hashed, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) if err != nil { return nil, err } - var n int64 - if err := s.db.Model(&model.User{}).Count(&n).Error; err != nil { + var user model.User + err = s.db.Transaction(func(tx *gorm.DB) error { + // 与站长角色迁移共用锁空间,避免并发首注抢多 owner + if err := tx.Exec("SELECT pg_advisory_xact_lock(?)", int64(0x6a31336f776e6572)).Error; err != nil { + return err + } + var count int64 + if err := tx.Model(&model.User{}).Where("username = ?", username).Count(&count).Error; err != nil { + return err + } + if count > 0 { + return errors.New("用户名已被使用") + } + var n int64 + if err := tx.Model(&model.User{}).Count(&n).Error; err != nil { + return err + } + role := model.RoleUser + if n == 0 { + role = model.RoleOwner + } + user = model.User{ + Username: username, + Email: email, + Password: string(hashed), + Nickname: username, + Role: role, + } + return tx.Create(&user).Error + }) + if err != nil { return nil, err } - role := model.RoleUser - if n == 0 { - role = model.RoleOwner - } - - user := &model.User{ - Username: username, - Email: email, - Password: string(hashed), - Nickname: username, - Role: role, - } - if err := s.db.Create(user).Error; err != nil { - return nil, err - } - return user, nil + return &user, nil } // Login 用户登录,返回 access token + refresh token + user @@ -479,18 +488,22 @@ func (s *AuthService) RotateRefreshToken(oldToken, ip, ua string) (string, strin } // RevokeRefreshToken 撤销单个 refresh token(单设备登出时用) -func (s *AuthService) RevokeRefreshToken(token string) { +func (s *AuthService) RevokeRefreshToken(token string) error { if token == "" { - return + return nil } - s.db.Model(&model.RefreshToken{}).Where("token_hash = ?", hashRefreshToken(token)). - Updates(map[string]any{"revoked": true, "token_cipher": ""}) + return s.db.Model(&model.RefreshToken{}).Where("token_hash = ?", hashRefreshToken(token)). + Updates(map[string]any{"revoked": true, "token_cipher": ""}).Error } // RevokeAllUserRefreshTokens 撤销用户所有 refresh token(改密码/封禁/盗用处置时用) -func (s *AuthService) RevokeAllUserRefreshTokens(userID uint) { - s.db.Model(&model.RefreshToken{}).Where("user_id = ? AND revoked = ?", userID, false). - Updates(map[string]any{"revoked": true, "token_cipher": ""}) +func (s *AuthService) RevokeAllUserRefreshTokens(userID uint) error { + return revokeAllUserRefreshTokensTx(s.db, userID) +} + +func revokeAllUserRefreshTokensTx(db *gorm.DB, userID uint) error { + return db.Model(&model.RefreshToken{}).Where("user_id = ? AND revoked = ?", userID, false). + Updates(map[string]any{"revoked": true, "token_cipher": ""}).Error } // PurgeExpiredRefreshTokens 物理清理过期或吊销超过保留期的行 @@ -518,18 +531,18 @@ func StartRefreshTokenCleanup(db *gorm.DB) { }() } -// IncrementTokenVersion 递增用户 token 版本,使所有已有 JWT 失效 -// 用于:改密码、封禁用户、管理员强制下线 +// IncrementTokenVersion 递增用户 token 版本并吊销全部 refresh(单事务) func (s *AuthService) IncrementTokenVersion(userID uint) error { - result := s.db.Model(&model.User{}).Where("id = ?", userID).UpdateColumn("token_version", gorm.Expr("token_version + 1")) - if result.Error != nil { - return result.Error - } - s.RevokeAllUserRefreshTokens(userID) - return nil + return s.db.Transaction(func(tx *gorm.DB) error { + if err := tx.Model(&model.User{}).Where("id = ?", userID). + UpdateColumn("token_version", gorm.Expr("token_version + 1")).Error; err != nil { + return err + } + return revokeAllUserRefreshTokensTx(tx, userID) + }) } -// ChangePassword 修改密码:校验旧密码,更新新密码,递增 token_version 使旧 token 失效 +// ChangePassword 修改密码:校验旧密码,更新新密码,事务内使旧会话全部失效 func (s *AuthService) ChangePassword(userID uint, oldPassword, newPassword string) error { var user model.User if err := s.db.First(&user, userID).Error; err != nil { @@ -545,15 +558,20 @@ func (s *AuthService) ChangePassword(userID uint, oldPassword, newPassword strin if err != nil { return err } - result := s.db.Model(&model.User{}).Where("id = ?", userID).Update("password", string(hashed)) - if result.Error != nil { - return result.Error - } - if result.RowsAffected == 0 { - return errors.New("密码更新失败") - } - // 递增 token_version,使所有旧 JWT 和 refresh token 失效 - return s.IncrementTokenVersion(userID) + return s.db.Transaction(func(tx *gorm.DB) error { + result := tx.Model(&model.User{}).Where("id = ?", userID).Update("password", string(hashed)) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + return errors.New("密码更新失败") + } + if err := tx.Model(&model.User{}).Where("id = ?", userID). + UpdateColumn("token_version", gorm.Expr("token_version + 1")).Error; err != nil { + return err + } + return revokeAllUserRefreshTokensTx(tx, userID) + }) } // UpdateProfile 更新昵称/邮箱/签名。头像只能通过上传接口(/api/upload/avatar) diff --git a/backend/service/operations_maintenance.go b/backend/service/operations_maintenance.go index 0decdda..7140901 100644 --- a/backend/service/operations_maintenance.go +++ b/backend/service/operations_maintenance.go @@ -38,7 +38,14 @@ func (o *Operations) ResetPassword(email, code, password string) error { if len(users) != 1 { return errors.New("账号不可用") } - return tx.Model(&model.User{}).Where("id = ?", users[0].ID).Updates(map[string]any{"password": string(hash), "token_version": gorm.Expr("token_version + 1")}).Error + if e := tx.Model(&model.User{}).Where("id = ?", users[0].ID).Updates(map[string]any{ + "password": string(hash), + "token_version": gorm.Expr("token_version + 1"), + }).Error; e != nil { + return e + } + return tx.Model(&model.RefreshToken{}).Where("user_id = ? AND revoked = ?", users[0].ID, false). + Updates(map[string]any{"revoked": true, "token_cipher": ""}).Error }) } func (o *Operations) Diagnostics(ctx context.Context) map[string]any { diff --git a/backend/service/timeline_git.go b/backend/service/timeline_git.go index 854fd25..fd1a766 100644 --- a/backend/service/timeline_git.go +++ b/backend/service/timeline_git.go @@ -67,6 +67,12 @@ func (s *SettingService) ImportTimelineFromGit(urls []string, followPages bool) client := &http.Client{ Timeout: timelineGitHTTPTimeout, + Transport: &http.Transport{ + // 解析后按公网 IP 拨号,避免 DNS rebinding(与 SMTP/S3 safeDial 同思路) + DialContext: publicOnlyDial, + TLSHandshakeTimeout: timelineGitHTTPTimeout, + ForceAttemptHTTP2: true, + }, CheckRedirect: func(req *http.Request, via []*http.Request) error { if len(via) >= 3 { return errors.New("重定向过多") @@ -656,6 +662,35 @@ func assertSafeHTTPSURL(u *url.URL) error { return nil } +// publicOnlyDial 先解析主机、拒绝私网,再按 IP 拨号,关闭 DNS rebinding 窗口。 +func publicOnlyDial(ctx context.Context, network, address string) (net.Conn, error) { + host, port, err := net.SplitHostPort(address) + if err != nil { + return nil, errors.New("连接地址无效") + } + ips, err := net.DefaultResolver.LookupIPAddr(ctx, host) + if err != nil || len(ips) == 0 { + return nil, errors.New("地址解析失败") + } + var last error + dialer := &net.Dialer{Timeout: timelineGitHTTPTimeout} + for _, a := range ips { + if !isPublicIP(a.IP) { + last = errors.New("禁止访问内网地址") + continue + } + c, e := dialer.DialContext(ctx, network, net.JoinHostPort(a.IP.String(), port)) + if e == nil { + return c, nil + } + last = e + } + if last == nil { + last = errors.New("禁止访问内网地址") + } + return nil, last +} + func isPublicIP(ip net.IP) bool { if ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsMulticast() || ip.IsUnspecified() { return false @@ -664,6 +699,10 @@ func isPublicIP(ip net.IP) bool { if ip4[0] == 169 && ip4[1] == 254 { return false } + // CGNAT / 文档网段等 + if ip4[0] == 100 && ip4[1] >= 64 && ip4[1] <= 127 { + return false + } } return true } diff --git a/deploy/.env.example b/deploy/.env.example index 54b86ad..dd17aab 100644 --- a/deploy/.env.example +++ b/deploy/.env.example @@ -22,8 +22,9 @@ POSTGRES_USER=jiang13 POSTGRES_PASSWORD=change-me POSTGRES_DB=jiang13 -# 生产必须显式设置。留空则首次启动写入 data/.jwt_secret(依赖 appdata volume)。 -JWT_SECRET=change-me-to-a-long-random-string +# 生产必须显式设置 ≥32 字符强随机(openssl rand -base64 32)。 +# 占位值 change-me / 过短密钥会被拒绝启动;也可留空由 data/.jwt_secret 自动生成。 +JWT_SECRET= # 正式站点 origin,生产使用 HTTPS;邮件 / Canonical / Sitemap 共用 # 32 字节随机值的标准 Base64;不填写时禁止保存邮件/S3凭据。独立备份,禁止提交真实密钥。 diff --git a/deploy/Dockerfile.web b/deploy/Dockerfile.web index 4cacc42..eeaf2fe 100644 --- a/deploy/Dockerfile.web +++ b/deploy/Dockerfile.web @@ -15,6 +15,7 @@ ARG VERSION=dev ARG BACKEND_URL=http://api:3001 ENV NEXT_TELEMETRY_DISABLED=1 \ NODE_ENV=production \ + DEV_MODE=false \ NEXT_PUBLIC_APP_VERSION=${VERSION} \ BACKEND_URL=${BACKEND_URL} RUN npm run build @@ -22,6 +23,7 @@ RUN npm run build FROM node:22-bookworm-slim AS runner WORKDIR /app ENV NODE_ENV=production \ + DEV_MODE=false \ PORT=3000 \ HOSTNAME=0.0.0.0 \ NEXT_TELEMETRY_DISABLED=1 \ diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index e57fe74..267cb4f 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -67,6 +67,7 @@ services: BACKEND_URL: http://api:3001 SITE_URL: ${SITE_URL:-https://localhost} NODE_ENV: production + DEV_MODE: "false" PORT: "3000" HOSTNAME: 0.0.0.0 ports: diff --git a/frontend/.env.example b/frontend/.env.example index fe974e7..0f1831e 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -6,9 +6,14 @@ NEXT_PUBLIC_API_URL=http://localhost:3001 # 仅服务端可见(middleware 与 SSR 优先使用)。 -# 部署到 Cloudflare Workers 等边缘环境时【必须】显式配置为后端 HTTPS 地址, -# 边缘运行时不存在 localhost,留空会导致 SSR 登录态轮转直接放行失效。 -# BACKEND_URL=https://your-backend.example.com +# 生产构建【必须】显式配置;缺省会直接失败,禁止烤进 localhost。 +# Docker 官方镜像构建期注入 http://api:3001。 +# BACKEND_URL=http://localhost:3001 + +# 与后端 DEV_MODE 对齐:true 时 cookie 无 __Host- 前缀。 +# 本地 next start 对着 DEV_MODE=true 的 Go 时请设为 true,避免 cookie 名分叉。 +# 官方 Docker 写死 false。 +DEV_MODE=true # 可选。官方 Docker 构建会写入与仓库根 VERSION 相同的值;本地 dev 可不设。 # NEXT_PUBLIC_APP_VERSION=0.1.0 diff --git a/frontend/app/admin/settings/SettingsShell.tsx b/frontend/app/admin/settings/SettingsShell.tsx index 037365e..9dc6e73 100644 --- a/frontend/app/admin/settings/SettingsShell.tsx +++ b/frontend/app/admin/settings/SettingsShell.tsx @@ -2,7 +2,7 @@ import Link from "next/link"; import { usePathname, useRouter } from "next/navigation"; -import { useLayoutEffect, useRef, type KeyboardEvent, type ReactNode } from "react"; +import { useRef, type KeyboardEvent, type ReactNode } from "react"; import { Globe } from "lucide-react"; import { AdminPageHeader, @@ -19,32 +19,11 @@ const NAV: { href: string; label: string }[] = [ { href: "/admin/settings/maintenance", label: "维护与诊断" }, ]; -/** 旧 #hash / ?section= → 独立路由 */ -const LEGACY: Record = { - basic: "/admin/settings/basic", - content: "/admin/settings/access", - security: "/admin/settings/access", - access: "/admin/settings/access", - mail: "/admin/settings/mail", - storage: "/admin/settings/storage", - filter: "/admin/settings/filter", - maintenance: "/admin/settings/maintenance", -}; - export default function SettingsShell({ children }: { children: ReactNode }) { const pathname = usePathname(); const router = useRouter(); const navRefs = useRef<(HTMLAnchorElement | null)[]>([]); - useLayoutEffect(() => { - const hash = window.location.hash.replace(/^#/, "").trim(); - const section = new URLSearchParams(window.location.search).get("section") || hash; - if (!section) return; - const dest = LEGACY[section]; - if (!dest || dest === pathname) return; - router.replace(dest); - }, [pathname, router]); - const onNavKeyDown = (e: KeyboardEvent, i: number) => { if ( e.key !== "ArrowDown" && diff --git a/frontend/app/admin/settings/page.tsx b/frontend/app/admin/settings/page.tsx index 57ad151..ee270e3 100644 --- a/frontend/app/admin/settings/page.tsx +++ b/frontend/app/admin/settings/page.tsx @@ -5,24 +5,7 @@ export const metadata: Metadata = { title: "站点设置", }; -interface PageProps { - searchParams: Promise<{ section?: string }>; -} - -const LEGACY: Record = { - basic: "/admin/settings/basic", - content: "/admin/settings/access", - security: "/admin/settings/access", - access: "/admin/settings/access", - mail: "/admin/settings/mail", - storage: "/admin/settings/storage", - filter: "/admin/settings/filter", - maintenance: "/admin/settings/maintenance", -}; - -/** /admin/settings 与旧 ?section= 统一跳到子路由 */ -export default async function SettingsIndexPage({ searchParams }: PageProps) { - const sp = await searchParams; - const raw = typeof sp.section === "string" ? sp.section.trim() : ""; - redirect(LEGACY[raw] || "/admin/settings/basic"); +/** /admin/settings 统一进入基本信息子路由 */ +export default async function SettingsIndexPage() { + redirect("/admin/settings/basic"); } diff --git a/frontend/components/OperationalBanner.tsx b/frontend/components/OperationalBanner.tsx index f7ac67a..5de03b9 100644 --- a/frontend/components/OperationalBanner.tsx +++ b/frontend/components/OperationalBanner.tsx @@ -1,17 +1,56 @@ "use client"; -import {useEffect,useState} from "react"; -import {usePathname} from "next/navigation"; -import {apiOperations} from "@/lib/api"; -export default function OperationalBanner(){ - const path=usePathname(); - const [notice,setNotice]=useState(""); - useEffect(()=>{ - let active=true; - const refresh=()=>{if(document.visibilityState!=="visible")return; void apiOperations<{maintenance:{mode:string;title:string;message:string}} >("/api/site-state").then(s=>{if(active)setNotice(s.maintenance.mode==="readonly" ? (s.maintenance.message || "站点暂时只读,可以浏览,暂不能提交或修改内容。") : "");}).catch(()=>{});}; - refresh();const timer=setInterval(refresh,30000); - document.addEventListener("visibilitychange",refresh); - return ()=>{active=false;clearInterval(timer);document.removeEventListener("visibilitychange",refresh);}; - },[path]); - if(path.startsWith("/admin") || !notice)return null; - return
只读模式:{notice}
; + +import { useEffect, useState } from "react"; +import { usePathname } from "next/navigation"; +import { apiOperations } from "@/lib/api"; +import { realtime, RT_SETTINGS_CHANGED } from "@/lib/realtime"; +import { + VISIBLE_RECONCILE_GAP_MS, + onDocumentVisible, +} from "@/lib/visibilityReconcile"; + +/** 只读维护条:WS settings:changed 优先,回前台再校准(无定时轮询) */ +export default function OperationalBanner() { + const path = usePathname(); + const [notice, setNotice] = useState(""); + + useEffect(() => { + let active = true; + + const apply = (mode: string | undefined, message: string | undefined) => { + if (!active) return; + setNotice( + mode === "readonly" + ? message || "站点暂时只读,可以浏览,暂不能提交或修改内容。" + : "" + ); + }; + + const refresh = () => { + if (document.visibilityState !== "visible") return; + void apiOperations<{ + maintenance: { mode: string; title: string; message: string }; + }>("/api/site-state") + .then((s) => apply(s.maintenance?.mode, s.maintenance?.message)) + .catch(() => {}); + }; + + refresh(); + const offPush = realtime.on(RT_SETTINGS_CHANGED, () => refresh()); + const offVisible = onDocumentVisible(refresh, VISIBLE_RECONCILE_GAP_MS); + + return () => { + active = false; + offPush(); + offVisible(); + }; + }, [path]); + + if (path.startsWith("/admin") || !notice) return null; + return ( +
+ 只读模式: + {notice} +
+ ); } diff --git a/frontend/components/home/CommunityPanels.tsx b/frontend/components/home/CommunityPanels.tsx index 580183c..912af2e 100644 --- a/frontend/components/home/CommunityPanels.tsx +++ b/frontend/components/home/CommunityPanels.tsx @@ -378,6 +378,7 @@ function ActiveUsers({ users, allowComments }: { users: ActiveUser[]; allowComme /* ---------- 友情链接 ---------- */ function FriendLinks() { + if (FRIEND_LINKS.length === 0) return null; return (

diff --git a/frontend/lib/api.ts b/frontend/lib/api.ts index b15dc08..80f0c84 100644 --- a/frontend/lib/api.ts +++ b/frontend/lib/api.ts @@ -21,10 +21,19 @@ export type { FooterLink, FooterLinksAlign }; // API 基础配置 // 注意:客户端请求使用相对路径 /api/*,走 Next.js rewrite 代理到后端, // 这样浏览器视为同源,Cookie 自动携带,无需处理 CORS。 -// SSR/middleware 直连后端:优先服务端专用 BACKEND_URL(边缘部署必须显式配置, -// localhost 兜底只在本地开发有效),回退到 NEXT_PUBLIC_API_URL。 -const API_BASE = - process.env.BACKEND_URL || process.env.NEXT_PUBLIC_API_URL || "http://localhost:3001"; +// SSR/middleware 直连后端:优先 BACKEND_URL;生产禁止静默回落 localhost。 +const API_BASE = (() => { + const fromEnv = + process.env.BACKEND_URL || process.env.NEXT_PUBLIC_API_URL || ""; + if (fromEnv) return fromEnv; + if (process.env.NODE_ENV === "production") { + if (typeof window === "undefined") { + throw new Error("生产环境必须设置 BACKEND_URL(SSR 直连后端)"); + } + return ""; + } + return "http://localhost:3001"; +})(); // SSR 请求超时:后端不可用时快速降级为游客视图,不阻塞页面渲染 const SSR_TIMEOUT_MS = 8000; diff --git a/frontend/lib/cookies.ts b/frontend/lib/cookies.ts index 2d6cda1..618d7a4 100644 --- a/frontend/lib/cookies.ts +++ b/frontend/lib/cookies.ts @@ -1,7 +1,14 @@ // 认证 cookie 名(前后端必须严格一致): -// 生产构建(HTTPS)启用 __Host- 前缀,浏览器强制 Secure + Path=/ + 无 Domain; -// 后端按 !DevMode 同步启用(见 backend/service/auth.go ConfigureCookieNames)。 -const HOST_PREFIX = process.env.NODE_ENV === "production" ? "__Host-" : ""; +// 与后端 ConfigureCookieNames(!DevMode) 对齐——显式 DEV_MODE=true|1 禁用 __Host-; +// 显式 DEV_MODE=false|0 启用;未设置时回退 NODE_ENV=production(官方镜像两者同时满足)。 +function hostCookiePrefix(): string { + const dm = (process.env.DEV_MODE || "").trim().toLowerCase(); + if (dm === "true" || dm === "1") return ""; + if (dm === "false" || dm === "0") return "__Host-"; + return process.env.NODE_ENV === "production" ? "__Host-" : ""; +} + +const HOST_PREFIX = hostCookiePrefix(); export const TOKEN_COOKIE = `${HOST_PREFIX}j13_token`; export const REFRESH_COOKIE = `${HOST_PREFIX}j13_refresh`; diff --git a/frontend/lib/friendLinks.ts b/frontend/lib/friendLinks.ts index 7357a3d..087b3a0 100644 --- a/frontend/lib/friendLinks.ts +++ b/frontend/lib/friendLinks.ts @@ -1,11 +1,7 @@ -// 友情链接(站点级静态配置)。 -// TODO: 替换为真实友链名称与地址;后续如需后台可配,可升级为接口/设置项。 +// 友情链接(站点级静态配置)。无条目时侧栏不渲染该区块。 export interface FriendLink { name: string; href: string; } -export const FRIEND_LINKS: FriendLink[] = [ - { name: "大佬论坛", href: "#" }, - { name: "Leo论坛", href: "#" }, -]; +export const FRIEND_LINKS: FriendLink[] = []; diff --git a/frontend/next.config.ts b/frontend/next.config.ts index 806d98c..223366c 100644 --- a/frontend/next.config.ts +++ b/frontend/next.config.ts @@ -1,8 +1,17 @@ import type { NextConfig } from "next"; -// 后端地址:开发环境兜底 localhost:3001,生产部署必须显式配置 BACKEND_URL +// 后端地址:开发可兜底 localhost:3001;生产构建必须显式 BACKEND_URL(禁止静默烤进 localhost) +const isProd = process.env.NODE_ENV === "production"; const API_TARGET = - process.env.BACKEND_URL || process.env.NEXT_PUBLIC_API_URL || "http://localhost:3001"; + process.env.BACKEND_URL || + process.env.NEXT_PUBLIC_API_URL || + (isProd ? "" : "http://localhost:3001"); + +if (!API_TARGET) { + throw new Error( + "生产构建必须设置 BACKEND_URL(例如 Docker 内 http://api:3001),禁止回落 localhost" + ); +} const nextConfig: NextConfig = { // 官方 Docker 运行时:最小产物,不含完整 node_modules。next dev 忽略此项。