From 055688a6cf64a7e6aa6ac854b694a4cbfb6c5869 Mon Sep 17 00:00:00 2001 From: freefire Date: Sat, 12 Sep 2026 15:23:13 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E5=AE=8C=E6=88=90=E5=89=8D?= =?UTF-8?q?=E5=90=8E=E7=AB=AF=E8=AE=A4=E8=AF=81=E4=BD=93=E7=B3=BB=E9=87=8D?= =?UTF-8?q?=E6=9E=84=E4=B8=8E=E5=AE=89=E5=85=A8=E5=A2=9E=E5=BC=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 本提交重构了全栈的认证与安全体系,包含以下核心变更: 1. **后端安全增强**: - 使用常量时间比较修复CSRF校验时序漏洞 - 重构refresh token存储为哈希+密文,支持轮转宽限期与盗用检测 - 新增cookie前缀自动配置,支持__Host-前缀强化生产环境安全 - 优化登出逻辑为单设备登出,合并用户信息与未读通知接口 - 增加数据库迁移脚本,兼容旧版refresh token数据升级 - 新增定时清理过期refresh token任务 2. **前端安全与体验优化**: - 新增cookie工具库,统一管理认证cookie名与转发逻辑 - 修复开放重定向漏洞,实现安全跳转校验 - 重构SSR请求逻辑,仅转发必要的access token cookie - 优化middleware轮转逻辑,修复并发请求去重与边缘部署兼容性 - 修复登录态静默校正逻辑,兼容旧版cookie路径与名称 - 新增环境变量示例配置文件 3. **工程化改进**: 修复Next.js类型文件导入路径,统一前后端配置与命名规范 --- backend/cmd/jiang13/main.go | 6 + backend/handler/auth.go | 93 ++++++---- backend/middleware/csrf.go | 5 +- backend/model/db.go | 94 ++++++++++ backend/model/models.go | 23 ++- backend/service/auth.go | 301 ++++++++++++++++++++++++++----- frontend/.env.example | 11 ++ frontend/app/board/[id]/page.tsx | 3 +- frontend/app/layout.tsx | 27 ++- frontend/app/login/page.tsx | 10 +- frontend/app/page.tsx | 4 +- frontend/app/post/[id]/page.tsx | 3 +- frontend/components/Header.tsx | 25 ++- frontend/lib/api.ts | 85 +++++---- frontend/lib/cookies.ts | 28 +++ frontend/middleware.ts | 131 ++++++++++---- frontend/next-env.d.ts | 4 +- 17 files changed, 665 insertions(+), 188 deletions(-) create mode 100644 frontend/.env.example create mode 100644 frontend/lib/cookies.ts diff --git a/backend/cmd/jiang13/main.go b/backend/cmd/jiang13/main.go index 67c0b7b..9375c79 100644 --- a/backend/cmd/jiang13/main.go +++ b/backend/cmd/jiang13/main.go @@ -7,6 +7,7 @@ import ( "github.com/freefire/jiang13-bbs/config" "github.com/freefire/jiang13-bbs/model" "github.com/freefire/jiang13-bbs/router" + "github.com/freefire/jiang13-bbs/service" ) func main() { @@ -19,6 +20,11 @@ func main() { log.Fatalf("数据库初始化失败: %v", err) } + // 生产 HTTPS 启用 __Host- 认证 cookie 前缀(须在路由注册、读写 cookie 前配置) + service.ConfigureCookieNames(!cfg.DevMode) + // 过期/已吊销 refresh token 定期清理 + service.StartRefreshTokenCleanup(model.DB) + r, err := router.Setup(cfg) if err != nil { log.Fatalf("路由初始化失败: %v", err) diff --git a/backend/handler/auth.go b/backend/handler/auth.go index eaf5e5e..7d47bdd 100644 --- a/backend/handler/auth.go +++ b/backend/handler/auth.go @@ -5,6 +5,7 @@ import ( "time" "github.com/freefire/jiang13-bbs/middleware" + "github.com/freefire/jiang13-bbs/model" "github.com/freefire/jiang13-bbs/service" "github.com/gin-gonic/gin" ) @@ -15,14 +16,16 @@ var ( ) // setAuthCookies 设置认证 cookie: -// - j13_token: access token(HttpOnly,15min) -// - j13_refresh: refresh token(HttpOnly,7天,Path=/,供 Next middleware 在 -// 页面/RSC 请求中读取并静默轮转;HttpOnly+SameSite=Strict+CSRF 校验已足够防护) -// - j13_csrf: CSRF token(可读,7天,与 refresh 同生命周期,确保 refresh 流程可用) +// - access token(HttpOnly,15min) +// - refresh token(HttpOnly,7天,Path=/,供 Next middleware 在页面/RSC +// 请求中读取并静默轮转;仅 /api/auth/refresh 端点消费) +// - CSRF token(JS 可读,7天,双提交校验) +// SameSite=Lax:允许外站顶级链接进入时保留登录态(Strict 会导致从外站 +// 跳转进来的第一次请求丢 cookie,把已登录用户误判为游客);状态变更请求 +// 另有 CSRF 双提交 token 兜底。生产 HTTPS 下 cookie 名带 __Host- 前缀。 func setAuthCookies(c *gin.Context, accessToken, refreshToken string, secure bool) { csrfToken := service.GenerateCSRFToken() - // Access token cookie http.SetCookie(c.Writer, &http.Cookie{ Name: service.CookieName, Value: accessToken, @@ -30,10 +33,8 @@ func setAuthCookies(c *gin.Context, accessToken, refreshToken string, secure boo MaxAge: accessCookieMaxAge, HttpOnly: true, Secure: secure, - SameSite: http.SameSiteStrictMode, + SameSite: http.SameSiteLaxMode, }) - // Refresh token cookie:Path=/ 以支持 SSR middleware 续期; - // 仅 /api/auth/refresh 端点消费,其他路径不读取 http.SetCookie(c.Writer, &http.Cookie{ Name: service.RefreshCookieName, Value: refreshToken, @@ -41,9 +42,8 @@ func setAuthCookies(c *gin.Context, accessToken, refreshToken string, secure boo MaxAge: refreshCookieMaxAge, HttpOnly: true, Secure: secure, - SameSite: http.SameSiteStrictMode, + SameSite: http.SameSiteLaxMode, }) - // CSRF cookie(前端可读,放入 X-CSRF-Token header;与 refresh 同寿命以支持 refresh 流程) http.SetCookie(c.Writer, &http.Cookie{ Name: service.CSRFCookieName, Value: csrfToken, @@ -51,19 +51,31 @@ func setAuthCookies(c *gin.Context, accessToken, refreshToken string, secure boo MaxAge: refreshCookieMaxAge, HttpOnly: false, Secure: secure, - SameSite: http.SameSiteStrictMode, + SameSite: http.SameSiteLaxMode, }) } -// clearAuthCookies 清除所有认证 cookie -func clearAuthCookies(c *gin.Context) { - // refresh cookie 同时清除两个路径:Path=/api/auth 为旧版兼容 +// clearAuthCookies 清除所有认证 cookie。 +// 除当前名称外,还需兼容清除: +// - 启用 __Host- 前缀之前的旧无前缀名称(Path=/) +// - 更早期 Path=/api/auth 的旧 refresh cookie +func clearAuthCookies(c *gin.Context, secure bool) { cookies := []struct{ name, path string }{ {service.CookieName, "/"}, {service.RefreshCookieName, "/"}, - {service.RefreshCookieName, "/api/auth"}, {service.CSRFCookieName, "/"}, } + // 当前生产名称带 __Host- 前缀时,旧无前缀 cookie 仍残留在浏览器中 + if service.CookieName != "j13_token" { + cookies = append(cookies, + struct{ name, path string }{"j13_token", "/"}, + struct{ name, path string }{"j13_refresh", "/"}, + struct{ name, path string }{"j13_csrf", "/"}, + ) + } + // 更早期的 refresh cookie 只挂在 /api/auth 下 + cookies = append(cookies, struct{ name, path string }{"j13_refresh", "/api/auth"}) + for _, ck := range cookies { http.SetCookie(c.Writer, &http.Cookie{ Name: ck.name, @@ -71,8 +83,8 @@ func clearAuthCookies(c *gin.Context) { Path: ck.path, MaxAge: -1, HttpOnly: true, - Secure: false, - SameSite: http.SameSiteStrictMode, + Secure: secure, + SameSite: http.SameSiteLaxMode, Expires: time.Unix(0, 0), }) } @@ -143,7 +155,7 @@ func (h *Handlers) Refresh(c *gin.Context) { } accessToken, newRefresh, user, err := h.Auth.RotateRefreshToken(refreshToken) if err != nil { - clearAuthCookies(c) + clearAuthCookies(c, !h.Cfg.DevMode) c.JSON(http.StatusUnauthorized, gin.H{"error": "登录已过期,请重新登录"}) return } @@ -159,14 +171,14 @@ func (h *Handlers) Refresh(c *gin.Context) { }) } -// Logout 登出:撤销 refresh token,清除所有认证 cookie +// Logout 登出:仅撤销本次请求携带的那枚 refresh token(单设备登出语义, +// 不影响该用户其他设备的登录态),并清除所有认证 cookie。 +// access JWT 无状态、15 分钟自然过期;登出后 tv 不递增,属可接受的短窗口。 func (h *Handlers) Logout(c *gin.Context) { - claims := middleware.CurrentUser(c) - if claims != nil { - // 撤销该用户所有 refresh token(防止 refresh token 被盗用) - h.Auth.RevokeAllUserRefreshTokens(claims.ID) + if refreshToken, err := c.Cookie(service.RefreshCookieName); err == nil && refreshToken != "" { + h.Auth.RevokeRefreshToken(refreshToken) } - clearAuthCookies(c) + clearAuthCookies(c, !h.Cfg.DevMode) c.JSON(http.StatusOK, gin.H{"message": "已登出"}) } @@ -189,29 +201,36 @@ func (h *Handlers) ChangePassword(c *gin.Context) { return } // 密码已改,token_version 已递增,旧 token 全部失效,清除 cookie 要求重新登录 - clearAuthCookies(c) + clearAuthCookies(c, !h.Cfg.DevMode) c.JSON(http.StatusOK, gin.H{"message": "密码修改成功,请重新登录"}) } -// Me 获取当前用户信息 +// meUserBody 统一的用户信息响应体(Me/Login/Refresh 共用形状) +func meUserBody(user *model.User) gin.H { + return gin.H{ + "id": user.ID, + "username": user.Username, + "nickname": user.Nickname, + "avatar": user.Avatar, + "role": user.Role, + } +} + +// Me 获取当前用户信息 + 未读通知数(合并为一次请求,供 SSR layout 直出) func (h *Handlers) Me(c *gin.Context) { claims := middleware.CurrentUser(c) if claims == nil { - c.JSON(http.StatusOK, gin.H{"user": nil}) + c.JSON(http.StatusOK, gin.H{"user": nil, "unread_count": 0}) return } user, err := h.Auth.GetUserByID(claims.ID) if err != nil { - c.JSON(http.StatusOK, gin.H{"user": nil}) + c.JSON(http.StatusOK, gin.H{"user": nil, "unread_count": 0}) return } - c.JSON(http.StatusOK, gin.H{ - "user": gin.H{ - "id": user.ID, - "username": user.Username, - "nickname": user.Nickname, - "avatar": user.Avatar, - "role": user.Role, - }, - }) + var unread int64 + if n, err := h.Notification.UnreadCount(claims.ID); err == nil { + unread = n + } + c.JSON(http.StatusOK, gin.H{"user": meUserBody(user), "unread_count": unread}) } diff --git a/backend/middleware/csrf.go b/backend/middleware/csrf.go index be52ef8..6e72407 100644 --- a/backend/middleware/csrf.go +++ b/backend/middleware/csrf.go @@ -1,6 +1,7 @@ package middleware import ( + "crypto/subtle" "net/http" "github.com/freefire/jiang13-bbs/service" @@ -24,7 +25,9 @@ func CSRFMiddleware() gin.HandlerFunc { } headerToken := c.GetHeader(service.CSRFHeaderName) - if headerToken == "" || headerToken != cookieToken { + // 常量时间比较,避免通过响应耗时逐字节猜测 CSRF token + if headerToken == "" || + subtle.ConstantTimeCompare([]byte(headerToken), []byte(cookieToken)) != 1 { c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "CSRF 校验失败"}) return } diff --git a/backend/model/db.go b/backend/model/db.go index 4cadbbf..98a2184 100644 --- a/backend/model/db.go +++ b/backend/model/db.go @@ -1,6 +1,8 @@ package model import ( + "crypto/sha256" + "encoding/hex" "fmt" "log" @@ -21,12 +23,23 @@ func InitDB(dsn string) error { return fmt.Errorf("连接 PostgreSQL 失败: %w", err) } + // 旧表 refresh_tokens.token(明文)→ token_hash 体系:必须在 AutoMigrate + // 创建 NOT NULL 列/唯一索引之前完成回填 + if err := prepareRefreshTokenMigration(db); err != nil { + return fmt.Errorf("refresh token 旧数据迁移失败: %w", err) + } + if err := db.AutoMigrate( &User{}, &Board{}, &Post{}, &Comment{}, &RefreshToken{}, &Like{}, &Notification{}, ); err != nil { return fmt.Errorf("自动迁移失败: %w", err) } + // 新表结构就位后删除遗留的明文列 + if err := dropLegacyRefreshTokenColumn(db); err != nil { + return fmt.Errorf("refresh token 旧列清理失败: %w", err) + } + DB = db seedDefaultBoards(db) log.Println("[model] PostgreSQL 数据库初始化完成") @@ -45,6 +58,87 @@ func PingDB() error { return sqlDB.Ping() } +// prepareRefreshTokenMigration 旧版 refresh_tokens 表把明文存在 token 列, +// 新版改为 token_hash(SHA-256,NOT NULL+唯一索引)。在 AutoMigrate 之前: +// 1. 新增可带默认值的 token_hash 列(避免对存量行加 NOT NULL 列失败) +// 2. 用存量明文回填哈希 +// 3. AutoMigrate 随后补唯一索引/其余新列 +// +// 旧行无法回填 TokenCipher(密钥在 service 层),仅影响该行下一次轮转的 +// 并发重放宽限,属一次性边界;轮转后即完全进入新体系。 +func prepareRefreshTokenMigration(db *gorm.DB) error { + var tableCount int64 + if err := db.Raw(`SELECT count(1) FROM information_schema.tables WHERE table_name = 'refresh_tokens'`). + Scan(&tableCount).Error; err != nil { + return err + } + if tableCount == 0 { + return nil // 全新数据库,AutoMigrate 直接建新表 + } + + var hasHashCol int64 + if err := db.Raw(`SELECT count(1) FROM information_schema.columns + WHERE table_name = 'refresh_tokens' AND column_name = 'token_hash'`). + Scan(&hasHashCol).Error; err != nil { + return err + } + if hasHashCol > 0 { + return nil // 已是新结构 + } + + var hasLegacyCol int64 + if err := db.Raw(`SELECT count(1) FROM information_schema.columns + WHERE table_name = 'refresh_tokens' AND column_name = 'token'`). + Scan(&hasLegacyCol).Error; err != nil { + return err + } + if hasLegacyCol == 0 { + return nil + } + + if err := db.Exec(`DELETE FROM refresh_tokens WHERE token IS NULL OR token = ''`).Error; err != nil { + return err + } + if err := db.Exec(`ALTER TABLE refresh_tokens ADD COLUMN token_hash varchar(64) NOT NULL DEFAULT ''`).Error; err != nil { + return err + } + + type legacyRow struct { + ID uint + Token string + } + var rows []legacyRow + if err := db.Raw(`SELECT id, token FROM refresh_tokens`).Scan(&rows).Error; err != nil { + return err + } + for _, r := range rows { + sum := sha256.Sum256([]byte(r.Token)) + if err := db.Exec( + `UPDATE refresh_tokens SET token_hash = ? WHERE id = ?`, + hex.EncodeToString(sum[:]), r.ID, + ).Error; err != nil { + return err + } + } + log.Printf("[model] refresh_tokens 已回填 %d 行 token_hash", len(rows)) + return nil +} + +// dropLegacyRefreshTokenColumn 新结构就位后删除明文 token 列(PostgreSQL +// 会连带删除该列上的旧唯一索引) +func dropLegacyRefreshTokenColumn(db *gorm.DB) error { + var hasLegacyCol int64 + if err := db.Raw(`SELECT count(1) FROM information_schema.columns + WHERE table_name = 'refresh_tokens' AND column_name = 'token'`). + Scan(&hasLegacyCol).Error; err != nil { + return err + } + if hasLegacyCol == 0 { + return nil + } + return db.Exec(`ALTER TABLE refresh_tokens DROP COLUMN token`).Error +} + // seedDefaultBoards 写入默认板块 func seedDefaultBoards(db *gorm.DB) { defaults := []Board{ diff --git a/backend/model/models.go b/backend/model/models.go index 288d07f..83b23f4 100644 --- a/backend/model/models.go +++ b/backend/model/models.go @@ -43,15 +43,22 @@ type User struct { DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` } -// RefreshToken 刷新令牌表(支持服务端撤销与轮转) +// RefreshToken 刷新令牌表(支持服务端撤销、一次性轮转与盗用检测) +// - TokenHash: token 的 SHA-256,数据库不存明文 +// - TokenCipher: token 的 AES-GCM 密文,仅存在于"当前有效"行;轮转后旧行立即抹除。 +// 保留它是为了在轮转宽限期内把【同一个】新 token 返回给并发重放请求(见 service 层) +// - RotatedTo/RotatedAt: 轮转链,用于宽限重放判定与盗用(吊销后重放)杀全家族 type RefreshToken struct { - ID uint `gorm:"primaryKey" json:"id"` - UserID uint `gorm:"index;not null" json:"user_id"` - Token string `gorm:"uniqueIndex;size:128;not null" json:"-"` - ExpiresAt time.Time `gorm:"index;not null" json:"expires_at"` - Revoked bool `gorm:"default:false" json:"revoked"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID uint `gorm:"primaryKey" json:"id"` + UserID uint `gorm:"index;not null" json:"user_id"` + TokenHash string `gorm:"uniqueIndex;size:64;not null" json:"-"` + TokenCipher string `gorm:"size:512;default:''" json:"-"` + ExpiresAt time.Time `gorm:"index;not null" json:"expires_at"` + Revoked bool `gorm:"default:false;index" json:"revoked"` + RotatedTo uint `gorm:"index;default:0" json:"-"` + RotatedAt *time.Time `gorm:"index" json:"-"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } // Board 论坛板块 diff --git a/backend/service/auth.go b/backend/service/auth.go index 2963fae..de7ef0f 100644 --- a/backend/service/auth.go +++ b/backend/service/auth.go @@ -1,27 +1,28 @@ package service import ( + "crypto/aes" + "crypto/cipher" "crypto/rand" + "crypto/sha256" "encoding/base64" + "encoding/hex" "errors" + "io" + "log" "time" "github.com/freefire/jiang13-bbs/model" "github.com/golang-jwt/jwt/v5" "golang.org/x/crypto/bcrypt" "gorm.io/gorm" + "gorm.io/gorm/clause" ) const ( RoleUser = "user" RoleAdmin = "admin" - // CookieName JWT access token 存储的 HttpOnly cookie 名 - CookieName = "j13_token" - // RefreshCookieName refresh token 存储的 HttpOnly cookie 名 - RefreshCookieName = "j13_refresh" - // CSRFCookieName CSRF token cookie 名(非 HttpOnly,前端可读) - CSRFCookieName = "j13_csrf" // CSRFHeaderName 前端传递 CSRF token 的 header 名 CSRFHeaderName = "X-CSRF-Token" @@ -29,8 +30,35 @@ const ( AccessTokenTTL = 15 * time.Minute // RefreshTokenTTL refresh token 有效期(长期) RefreshTokenTTL = 7 * 24 * time.Hour + // RefreshRotationGrace 轮转宽限期:同枚旧 refresh 在此窗口内被并发重放时, + // 返回同一个新 token 对(放行),超过窗口的重放视为盗用(OAuth BCP 模式) + RefreshRotationGrace = 60 * time.Second + // RefreshTokenRetention 已过期/已吊销行的保留期:过期后仍需短期留痕用于盗用检测 + RefreshTokenRetention = 30 * 24 * time.Hour ) +// 认证 cookie 名(变量,生产 HTTPS 下通过 ConfigureCookieNames 启用 __Host- 前缀) +var ( + // CookieName JWT access token 存储的 HttpOnly cookie 名 + CookieName = "j13_token" + // RefreshCookieName refresh token 存储的 HttpOnly cookie 名 + RefreshCookieName = "j13_refresh" + // CSRFCookieName CSRF token cookie 名(非 HttpOnly,前端可读) + CSRFCookieName = "j13_csrf" +) + +// ConfigureCookieNames 生产环境(HTTPS)启用 __Host- 前缀: +// 浏览器强制 Secure、Path=/、禁止 Domain,进一步收窄 cookie 作用面。 +// 必须在路由注册前调用一次;前端按 NODE_ENV=production 同步启用。 +func ConfigureCookieNames(useHostPrefix bool) { + if !useHostPrefix { + return + } + CookieName = "__Host-j13_token" + RefreshCookieName = "__Host-j13_refresh" + CSRFCookieName = "__Host-j13_csrf" +} + // UserClaims JWT 中携带的用户信息 type UserClaims struct { ID uint `json:"id"` @@ -44,12 +72,19 @@ type UserClaims struct { type AuthService struct { db *gorm.DB jwtSecret []byte + encKey []byte // refresh token 落库密文的 AES-GCM 密钥(由 JWT 密钥派生) } func NewAuthService(db *gorm.DB, jwtSecret string) *AuthService { - return &AuthService{db: db, jwtSecret: []byte(jwtSecret)} + // 域分离派生:即使与 JWT 共用主密钥,密文用途也不可互换 + keySum := sha256.Sum256([]byte("j13-refresh-token-aesgcm-v1:" + jwtSecret)) + return &AuthService{db: db, jwtSecret: []byte(jwtSecret), encKey: keySum[:]} } +// dummyPasswordHash 用户不存在时也执行一次 bcrypt 比较,避免通过响应耗时枚举用户名 +var dummyPasswordHash, _ = bcrypt.GenerateFromPassword( + []byte("j13-dummy-password-for-constant-timing"), bcrypt.DefaultCost) + // Register 用户注册 func (s *AuthService) Register(username, email, password string) (*model.User, error) { // 检查用户名是否已存在 @@ -81,14 +116,17 @@ func (s *AuthService) Register(username, email, password string) (*model.User, e func (s *AuthService) Login(username, password string) (string, string, *model.User, error) { var user model.User if err := s.db.Where("username = ?", username).First(&user).Error; err != nil { + // 仍然消耗一次 bcrypt 比较的时间,避免用户名存在性时序枚举 + _ = bcrypt.CompareHashAndPassword(dummyPasswordHash, []byte(password)) return "", "", nil, errors.New("用户名或密码错误") } - if user.Banned { - return "", "", nil, errors.New("账号已被封禁") - } if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)); err != nil { return "", "", nil, errors.New("用户名或密码错误") } + // 封禁判定放在密码比较之后,同样避免时序差异 + if user.Banned { + return "", "", nil, errors.New("账号已被封禁") + } accessToken, err := s.generateToken(&user) if err != nil { @@ -179,13 +217,73 @@ func generateRandomToken() string { return base64.URLEncoding.EncodeToString(b) } -// CreateRefreshToken 创建并存储 refresh token +// refresh token 相关错误 +var ( + ErrRefreshInvalid = errors.New("refresh token 无效") + ErrRefreshExpired = errors.New("refresh token 已过期") + ErrRefreshReused = errors.New("refresh token 存在重用嫌疑") +) + +// hashRefreshToken refresh token 只以 SHA-256 形式落库 +func hashRefreshToken(token string) string { + sum := sha256.Sum256([]byte(token)) + return hex.EncodeToString(sum[:]) +} + +// encryptToken / decryptToken:当前有效的 refresh token 保留一份 AES-GCM +// 密文,仅用于轮转宽限期内向并发重放返回同一个新 token;行一旦吊销立即抹除 +func (s *AuthService) encryptToken(token string) (string, error) { + block, err := aes.NewCipher(s.encKey) + if err != nil { + return "", err + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", err + } + nonce := make([]byte, gcm.NonceSize()) + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { + return "", err + } + cipherText := gcm.Seal(nonce, nonce, []byte(token), nil) + return base64.StdEncoding.EncodeToString(cipherText), nil +} + +func (s *AuthService) decryptToken(encoded string) (string, error) { + data, err := base64.StdEncoding.DecodeString(encoded) + if err != nil { + return "", err + } + block, err := aes.NewCipher(s.encKey) + if err != nil { + return "", err + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", err + } + if len(data) < gcm.NonceSize() { + return "", errors.New("密文过短") + } + plain, err := gcm.Open(nil, data[:gcm.NonceSize()], data[gcm.NonceSize():], nil) + if err != nil { + return "", err + } + return string(plain), nil +} + +// CreateRefreshToken 创建并存储 refresh token(存哈希 + 密文) func (s *AuthService) CreateRefreshToken(userID uint) (string, error) { token := generateRandomToken() + cipherText, err := s.encryptToken(token) + if err != nil { + return "", err + } rt := &model.RefreshToken{ - UserID: userID, - Token: token, - ExpiresAt: time.Now().Add(RefreshTokenTTL), + UserID: userID, + TokenHash: hashRefreshToken(token), + TokenCipher: cipherText, + ExpiresAt: time.Now().Add(RefreshTokenTTL), } if err := s.db.Create(rt).Error; err != nil { return "", err @@ -193,20 +291,10 @@ func (s *AuthService) CreateRefreshToken(userID uint) (string, error) { return token, nil } -// ValidateRefreshToken 校验 refresh token 并返回所属用户 -func (s *AuthService) ValidateRefreshToken(token string) (*model.User, error) { - var rt model.RefreshToken - if err := s.db.Where("token = ?", token).First(&rt).Error; err != nil { - return nil, errors.New("refresh token 无效") - } - if rt.Revoked { - return nil, errors.New("refresh token 已撤销") - } - if time.Now().After(rt.ExpiresAt) { - return nil, errors.New("refresh token 已过期") - } +// loadActiveUser 加载用户并实时校验封禁状态 +func (s *AuthService) loadActiveUser(tx *gorm.DB, userID uint) (*model.User, error) { var user model.User - if err := s.db.First(&user, rt.UserID).Error; err != nil { + if err := tx.First(&user, userID).Error; err != nil { return nil, errors.New("用户不存在") } if user.Banned { @@ -215,34 +303,155 @@ func (s *AuthService) ValidateRefreshToken(token string) (*model.User, error) { return &user, nil } -// RotateRefreshToken 轮转 refresh token:撤销旧的,签发新的 +// ValidateRefreshToken 校验 refresh token 并返回所属用户 +func (s *AuthService) ValidateRefreshToken(token string) (*model.User, error) { + var rt model.RefreshToken + if err := s.db.Where("token_hash = ?", hashRefreshToken(token)).First(&rt).Error; err != nil { + return nil, ErrRefreshInvalid + } + if rt.Revoked { + return nil, errors.New("refresh token 已撤销") + } + if time.Now().After(rt.ExpiresAt) { + return nil, ErrRefreshExpired + } + return s.loadActiveUser(s.db, rt.UserID) +} + +// RotateRefreshToken 一次性轮转 refresh token(整个判定与写库在单事务内完成): +// - 正常:旧行吊销并链接到新行,返回新 access + 新 refresh +// - 宽限期并发重放(RSC 预取/多实例几乎同时到达):返回【同一个】新 token 对, +// 不再轮转,避免浏览器收到互相吊销的两套 cookie 而随机掉线 +// - 吊销后重放(被盗信号):撤销该用户全部 refresh token,强制重新登录 func (s *AuthService) RotateRefreshToken(oldToken string) (string, string, *model.User, error) { - user, err := s.ValidateRefreshToken(oldToken) - if err != nil { - return "", "", nil, err + var ( + accessToken, newRefresh string + outUser *model.User + outErr error + ) + txErr := s.db.Transaction(func(tx *gorm.DB) error { + var rt model.RefreshToken + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). + Where("token_hash = ?", hashRefreshToken(oldToken)).First(&rt).Error; err != nil { + outErr = ErrRefreshInvalid + return nil + } + now := time.Now() + if now.After(rt.ExpiresAt) { + outErr = ErrRefreshExpired + return nil + } + user, err := s.loadActiveUser(tx, rt.UserID) + if err != nil { + outErr = err + return nil + } + + if rt.Revoked { + // 宽限期内、且轮转链指向的新 token 仍健康:并发重放,返回同一对 + if rt.RotatedTo != 0 && rt.RotatedAt != nil && now.Sub(*rt.RotatedAt) <= RefreshRotationGrace { + var next model.RefreshToken + if e := tx.First(&next, rt.RotatedTo).Error; e == nil && + next.UserID == rt.UserID && !next.Revoked && now.Before(next.ExpiresAt) && + next.TokenCipher != "" { + if sameRefresh, e := s.decryptToken(next.TokenCipher); e == nil { + if at, e := s.generateToken(user); e == nil { + accessToken, newRefresh, outUser = at, sameRefresh, user + return nil + } + } + } + } + // 其他吊销后重放:判定为盗用,撤销该用户整个 refresh token 家族 + if err := tx.Model(&model.RefreshToken{}). + Where("user_id = ? AND revoked = ?", rt.UserID, false). + Updates(map[string]any{"revoked": true, "token_cipher": "", "updated_at": now}).Error; err != nil { + return err + } + log.Printf("[auth] 检测到 refresh token 吊销后重放,已撤销 user=%d 的全部 refresh token", rt.UserID) + outErr = ErrRefreshReused + return nil + } + + // 正常轮转:先建新行,再吊销旧行并建立轮转链,同时抹除旧行密文 + plain := generateRandomToken() + cipherText, err := s.encryptToken(plain) + if err != nil { + return err + } + nr := &model.RefreshToken{ + UserID: rt.UserID, + TokenHash: hashRefreshToken(plain), + TokenCipher: cipherText, + ExpiresAt: now.Add(RefreshTokenTTL), + } + if err := tx.Create(nr).Error; err != nil { + return err + } + rotatedAt := now + if err := tx.Model(&rt).Updates(map[string]any{ + "revoked": true, + "rotated_to": nr.ID, + "rotated_at": rotatedAt, + "token_cipher": "", + "updated_at": now, + }).Error; err != nil { + return err + } + at, err := s.generateToken(user) + if err != nil { + return err + } + accessToken, newRefresh, outUser = at, plain, user + return nil + }) + if txErr != nil { + return "", "", nil, txErr } - // 撤销旧 token - s.db.Model(&model.RefreshToken{}).Where("token = ?", oldToken).Update("revoked", true) - // 签发新 access + refresh - accessToken, err := s.generateToken(user) - if err != nil { - return "", "", nil, err + if outErr != nil { + return "", "", nil, outErr } - newRefresh, err := s.CreateRefreshToken(user.ID) - if err != nil { - return "", "", nil, err - } - return accessToken, newRefresh, user, nil + return accessToken, newRefresh, outUser, nil } -// RevokeRefreshToken 撤销单个 refresh token(登出时用) +// RevokeRefreshToken 撤销单个 refresh token(单设备登出时用) func (s *AuthService) RevokeRefreshToken(token string) { - s.db.Model(&model.RefreshToken{}).Where("token = ?", token).Update("revoked", true) + if token == "" { + return + } + s.db.Model(&model.RefreshToken{}).Where("token_hash = ?", hashRefreshToken(token)). + Updates(map[string]any{"revoked": true, "token_cipher": ""}) } -// RevokeAllUserRefreshTokens 撤销用户所有 refresh token(改密码/封禁时用) +// RevokeAllUserRefreshTokens 撤销用户所有 refresh token(改密码/封禁/盗用处置时用) func (s *AuthService) RevokeAllUserRefreshTokens(userID uint) { - s.db.Model(&model.RefreshToken{}).Where("user_id = ?", userID).Update("revoked", true) + s.db.Model(&model.RefreshToken{}).Where("user_id = ? AND revoked = ?", userID, false). + Updates(map[string]any{"revoked": true, "token_cipher": ""}) +} + +// PurgeExpiredRefreshTokens 物理清理过期或吊销超过保留期的行 +// (保留期内仍需留痕,用于吊销后重放的盗用检测) +func PurgeExpiredRefreshTokens(db *gorm.DB) { + cutoff := time.Now().Add(-RefreshTokenRetention) + if err := db.Where("expires_at < ?", cutoff).Delete(&model.RefreshToken{}).Error; err != nil { + log.Printf("[auth] 清理过期 refresh token 失败: %v", err) + } + if err := db.Where("revoked = ? AND updated_at < ?", true, cutoff). + Delete(&model.RefreshToken{}).Error; err != nil { + log.Printf("[auth] 清理已吊销 refresh token 失败: %v", err) + } +} + +// StartRefreshTokenCleanup 启动周期清理任务(立即执行一次,之后每 6 小时) +func StartRefreshTokenCleanup(db *gorm.DB) { + go func() { + PurgeExpiredRefreshTokens(db) + ticker := time.NewTicker(6 * time.Hour) + defer ticker.Stop() + for range ticker.C { + PurgeExpiredRefreshTokens(db) + } + }() } // IncrementTokenVersion 递增用户 token 版本,使所有已有 JWT 失效 diff --git a/frontend/.env.example b/frontend/.env.example new file mode 100644 index 0000000..d5d6695 --- /dev/null +++ b/frontend/.env.example @@ -0,0 +1,11 @@ +# 前端环境变量示例:复制为 .env.local(本地)或在部署平台配置 +# 客户端交互请求走同源相对路径 /api/*(由 next.config.ts rewrite 代理), +# 以下地址仅用于 SSR 服务端渲染与 middleware 静默轮转直连后端。 + +# SSR/浏览器构建均可读;本地开发默认值即 http://localhost:3001 +NEXT_PUBLIC_API_URL=http://localhost:3001 + +# 仅服务端可见(middleware 与 SSR 优先使用)。 +# 部署到 Cloudflare Workers 等边缘环境时【必须】显式配置为后端 HTTPS 地址, +# 边缘运行时不存在 localhost,留空会导致 SSR 登录态轮转直接放行失效。 +# BACKEND_URL=https://your-backend.example.com diff --git a/frontend/app/board/[id]/page.tsx b/frontend/app/board/[id]/page.tsx index 16e4005..f0e6f33 100644 --- a/frontend/app/board/[id]/page.tsx +++ b/frontend/app/board/[id]/page.tsx @@ -3,6 +3,7 @@ import type { Metadata } from "next"; import { cookies } from "next/headers"; import { PenSquare, MessagesSquare, ChevronRight } from "lucide-react"; import { fetchPosts, fetchBoards } from "@/lib/api"; +import { authCookieHeader } from "@/lib/cookies"; import Pagination from "@/components/Pagination"; import PostRow from "@/components/PostRow"; import PinnedPosts from "@/components/PinnedPosts"; @@ -26,7 +27,7 @@ export default async function BoardPage({ params, searchParams }: PageProps) { const { id } = await params; const { page: pageStr } = await searchParams; const page = Math.max(1, parseInt(pageStr || "1", 10) || 1); - const cookie = (await cookies()).toString(); + const cookie = authCookieHeader(await cookies()); const [postsData, boardsData] = await Promise.all([ fetchPosts(page, 20, Number(id), "latest", "", cookie), fetchBoards(), diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx index 6861553..9215232 100644 --- a/frontend/app/layout.tsx +++ b/frontend/app/layout.tsx @@ -5,7 +5,8 @@ import "./globals.css"; import Header from "@/components/Header"; import Footer from "@/components/Footer"; import Toaster from "@/components/Toaster"; -import { fetchMe, fetchUnreadCount, type User } from "@/lib/api"; +import { fetchMe, type User } from "@/lib/api"; +import { TOKEN_COOKIE, REFRESH_COOKIE, authCookieHeader } from "@/lib/cookies"; // 构建期下载并自托管字体:同源加载 + 自动 preload + fallback 度量校正, // 消除 F5 时 Google CDN 字体到达后整页"由粗变细"的 FOUT 闪动 @@ -52,21 +53,19 @@ export default async function RootLayout({ const theme = cookieStore.get("j13-theme")?.value === "dark" ? "dark" : "light"; // SSR 直出登录态(access 过期时 middleware 已先行轮转,见 middleware.ts), - // 保证 F5 初始 HTML 中的右上角就是正确的用户区,而非先闪"登录/注册" - const cookieHeader = cookieStore.toString(); + // 保证 F5 初始 HTML 中的右上角就是正确的用户区,而非先闪"登录/注册"。 + // 旧版 refresh cookie 可能是 Path=/api/auth(页面请求不带),因此只要 + // token 或 refresh 任一存在就向后端确认一次,避免半登录态(列表点赞态与 + // Header 游客态不一致),并兼容 access 15 分钟内的自然窗口。 let user: User | null = null; let unread = 0; - if (cookieStore.get("j13_refresh")?.value) { - try { - const [me, count] = await Promise.all([ - fetchMe(cookieHeader), - fetchUnreadCount(cookieHeader), - ]); - user = me.user; - unread = count; - } catch { - // 后端不可用时降级为游客视图,不阻塞页面渲染 - } + const hasAuthCookie = + !!cookieStore.get(TOKEN_COOKIE)?.value || !!cookieStore.get(REFRESH_COOKIE)?.value; + if (hasAuthCookie) { + // 用户信息与未读数合并为一次请求;只转发最小化的 access cookie + const me = await fetchMe(authCookieHeader(cookieStore) || undefined); + user = me.user; + unread = me.unread_count; } return ( diff --git a/frontend/app/login/page.tsx b/frontend/app/login/page.tsx index cb0642e..9c7ef51 100644 --- a/frontend/app/login/page.tsx +++ b/frontend/app/login/page.tsx @@ -6,10 +6,18 @@ import Link from "next/link"; import { MessagesSquare } from "lucide-react"; import { apiLogin } from "@/lib/api"; +// 仅允许同源相对路径跳转,拦截 //host、/\\host 与绝对 URL 形式的开放重定向 +function safeRedirect(to: string | null): string { + if (!to || !to.startsWith("/") || to.startsWith("//") || to.startsWith("/\\")) { + return "/"; + } + return to; +} + export default function LoginPage() { const router = useRouter(); const searchParams = useSearchParams(); - const redirect = searchParams.get("redirect") || "/"; + const redirect = safeRedirect(searchParams.get("redirect")); const [username, setUsername] = useState(""); const [password, setPassword] = useState(""); const [error, setError] = useState(""); diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx index 232f0a3..d29d100 100644 --- a/frontend/app/page.tsx +++ b/frontend/app/page.tsx @@ -3,6 +3,7 @@ import type { Metadata } from "next"; import { cookies } from "next/headers"; import { PenSquare, SearchX, MessagesSquare, Check, ArrowRight } from "lucide-react"; import { fetchPosts, fetchBoards } from "@/lib/api"; +import { authCookieHeader } from "@/lib/cookies"; import Pagination from "@/components/Pagination"; import PostRow from "@/components/PostRow"; import PinnedPosts from "@/components/PinnedPosts"; @@ -20,7 +21,8 @@ export default async function HomePage({ searchParams }: HomePageProps) { const { q, page: pageStr } = await searchParams; const keyword = q?.trim() || ""; const page = Math.max(1, parseInt(pageStr || "1", 10) || 1); - const cookie = (await cookies()).toString(); + // 仅转发最小化的 access cookie 供后端填充 liked 状态(不携带 refresh 等) + const cookie = authCookieHeader(await cookies()); const [postsData, boardsData] = await Promise.all([ fetchPosts(page, 20, undefined, "latest", keyword, cookie), fetchBoards(), diff --git a/frontend/app/post/[id]/page.tsx b/frontend/app/post/[id]/page.tsx index 18c98cf..84a00cb 100644 --- a/frontend/app/post/[id]/page.tsx +++ b/frontend/app/post/[id]/page.tsx @@ -3,6 +3,7 @@ import Link from "next/link"; import { cookies } from "next/headers"; import ReactMarkdown from "react-markdown"; import { fetchPostDetail, fetchComments } from "@/lib/api"; +import { authCookieHeader } from "@/lib/cookies"; import CommentSection from "@/components/CommentSection"; import PostActions from "@/components/PostActions"; import LikeButton from "@/components/LikeButton"; @@ -35,7 +36,7 @@ export async function generateMetadata({ params }: PageProps): Promise export default async function PostDetailPage({ params }: PageProps) { const { id } = await params; - const cookie = (await cookies()).toString(); + const cookie = authCookieHeader(await cookies()); const { post } = await fetchPostDetail(id, cookie); const { comments } = await fetchComments(id); diff --git a/frontend/components/Header.tsx b/frontend/components/Header.tsx index df0078f..98e0767 100644 --- a/frontend/components/Header.tsx +++ b/frontend/components/Header.tsx @@ -17,7 +17,8 @@ import { Bell, Home, } from "lucide-react"; -import { apiLogout, type User } from "@/lib/api"; +import { apiLogout, apiMe, type User } from "@/lib/api"; +import { hasAuthCookieHint } from "@/lib/cookies"; import NotificationBell from "./NotificationBell"; import Avatar from "./Avatar"; @@ -36,6 +37,7 @@ export default function Header({ const [theme, setTheme] = useState<"light" | "dark">(initialTheme); // 登录态以 SSR 直出为初始值,F5 无切换闪动;router.refresh() 后随 props 校正 const [user, setUser] = useState(initialUser); + const [unread, setUnread] = useState(initialUnread); const [searchQuery, setSearchQuery] = useState(""); const searchInputRef = useRef(null); @@ -44,6 +46,23 @@ export default function Header({ setUser(initialUser); }, [initialUser]); + useEffect(() => { + setUnread(initialUnread); + }, [initialUnread]); + + // 挂载后静默校正一次(仅一次):覆盖 SSR 期间后端不可用、旧 Path refresh + // 半登录等边缘情况。正常 F5 下结果与 SSR 一致,不产生可见闪动; + // 无可读 CSRF cookie 说明必然是游客,跳过请求 + useEffect(() => { + if (!hasAuthCookieHint()) return; + apiMe() + .then((res) => { + setUser(res.user); + if (typeof res.unread_count === "number") setUnread(res.unread_count); + }) + .catch(() => {}); + }, []); + const handleSearch = (e: React.FormEvent) => { e.preventDefault(); const q = searchQuery.trim(); @@ -184,7 +203,7 @@ export default function Header({ - {user && } + {user && } {user ? ( <> @@ -221,7 +240,7 @@ export default function Header({ {/* 移动端按钮 */}
- {user && } + {user && }