开源前加固认证与部署契约:加密随机 JWT、改密事务吊销、登录 CSRF,并禁止生产回落 localhost。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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 优先于此处。丢失后已加密凭据无法解密,不要提交真实值。
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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": "已登出"})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 仍校验)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user