refactor: 完成前后端认证体系重构与安全增强
本提交重构了全栈的认证与安全体系,包含以下核心变更: 1. **后端安全增强**: - 使用常量时间比较修复CSRF校验时序漏洞 - 重构refresh token存储为哈希+密文,支持轮转宽限期与盗用检测 - 新增cookie前缀自动配置,支持__Host-前缀强化生产环境安全 - 优化登出逻辑为单设备登出,合并用户信息与未读通知接口 - 增加数据库迁移脚本,兼容旧版refresh token数据升级 - 新增定时清理过期refresh token任务 2. **前端安全与体验优化**: - 新增cookie工具库,统一管理认证cookie名与转发逻辑 - 修复开放重定向漏洞,实现安全跳转校验 - 重构SSR请求逻辑,仅转发必要的access token cookie - 优化middleware轮转逻辑,修复并发请求去重与边缘部署兼容性 - 修复登录态静默校正逻辑,兼容旧版cookie路径与名称 - 新增环境变量示例配置文件 3. **工程化改进**: 修复Next.js类型文件导入路径,统一前后端配置与命名规范
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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 获取当前用户信息
|
||||
func (h *Handlers) Me(c *gin.Context) {
|
||||
claims := middleware.CurrentUser(c)
|
||||
if claims == nil {
|
||||
c.JSON(http.StatusOK, gin.H{"user": nil})
|
||||
return
|
||||
}
|
||||
user, err := h.Auth.GetUserByID(claims.ID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"user": nil})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"user": gin.H{
|
||||
// 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, "unread_count": 0})
|
||||
return
|
||||
}
|
||||
user, err := h.Auth.GetUserByID(claims.ID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"user": nil, "unread_count": 0})
|
||||
return
|
||||
}
|
||||
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})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -43,13 +43,20 @@ 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:"-"`
|
||||
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" json:"revoked"`
|
||||
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"`
|
||||
}
|
||||
|
||||
@@ -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,12 +217,72 @@ 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,
|
||||
TokenHash: hashRefreshToken(token),
|
||||
TokenCipher: cipherText,
|
||||
ExpiresAt: time.Now().Add(RefreshTokenTTL),
|
||||
}
|
||||
if err := s.db.Create(rt).Error; err != nil {
|
||||
@@ -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
|
||||
}
|
||||
// 撤销旧 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
|
||||
now := time.Now()
|
||||
if now.After(rt.ExpiresAt) {
|
||||
outErr = ErrRefreshExpired
|
||||
return nil
|
||||
}
|
||||
newRefresh, err := s.CreateRefreshToken(user.ID)
|
||||
user, err := s.loadActiveUser(tx, rt.UserID)
|
||||
if err != nil {
|
||||
return "", "", nil, err
|
||||
outErr = err
|
||||
return nil
|
||||
}
|
||||
return accessToken, newRefresh, user, 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
|
||||
}
|
||||
if outErr != nil {
|
||||
return "", "", nil, outErr
|
||||
}
|
||||
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 失效
|
||||
|
||||
11
frontend/.env.example
Normal file
11
frontend/.env.example
Normal file
@@ -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
|
||||
@@ -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(),
|
||||
|
||||
@@ -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),
|
||||
]);
|
||||
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 = count;
|
||||
} catch {
|
||||
// 后端不可用时降级为游客视图,不阻塞页面渲染
|
||||
}
|
||||
unread = me.unread_count;
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -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("");
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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<Metadata>
|
||||
|
||||
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);
|
||||
|
||||
|
||||
@@ -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<User | null>(initialUser);
|
||||
const [unread, setUnread] = useState(initialUnread);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const searchInputRef = useRef<HTMLInputElement>(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({
|
||||
<button onClick={toggleTheme} className={iconBtn} style={iconBtnStyle} aria-label="切换主题">
|
||||
{theme === "dark" ? <Sun size={17} /> : <Moon size={17} />}
|
||||
</button>
|
||||
{user && <NotificationBell initialUnread={initialUnread} />}
|
||||
{user && <NotificationBell initialUnread={unread} />}
|
||||
{user ? (
|
||||
<>
|
||||
<Link href="/compose" className="btn btn-primary btn-sm ml-1">
|
||||
@@ -221,7 +240,7 @@ export default function Header({
|
||||
|
||||
{/* 移动端按钮 */}
|
||||
<div className="flex items-center gap-2 lg:hidden">
|
||||
{user && <NotificationBell initialUnread={initialUnread} />}
|
||||
{user && <NotificationBell initialUnread={unread} />}
|
||||
<button
|
||||
onClick={() => setMenuOpen(!menuOpen)}
|
||||
className="w-10 h-10 flex items-center justify-center rounded-full"
|
||||
|
||||
@@ -1,8 +1,23 @@
|
||||
import { CSRF_COOKIE } from "./cookies";
|
||||
|
||||
// API 基础配置
|
||||
// 注意:客户端请求使用相对路径 /api/*,走 Next.js rewrite 代理到后端,
|
||||
// 这样浏览器视为同源,Cookie 自动携带,无需处理 CORS。
|
||||
// SSR 端(fetchPosts 等公开接口)直接请求后端 API_BASE。
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL || "http://localhost:3001";
|
||||
// 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 请求超时:后端不可用时快速降级为游客视图,不阻塞页面渲染
|
||||
const SSR_TIMEOUT_MS = 8000;
|
||||
|
||||
function ssrInit(headers?: HeadersInit): RequestInit {
|
||||
return {
|
||||
cache: "no-store",
|
||||
headers,
|
||||
signal: AbortSignal.timeout(SSR_TIMEOUT_MS),
|
||||
};
|
||||
}
|
||||
|
||||
// 类型定义
|
||||
export interface User {
|
||||
@@ -13,6 +28,12 @@ export interface User {
|
||||
role: string;
|
||||
}
|
||||
|
||||
// /api/me 响应:用户信息 + 未读通知数(SSR layout 一次请求直出)
|
||||
export interface MeResponse {
|
||||
user: User | null;
|
||||
unread_count: number;
|
||||
}
|
||||
|
||||
export interface Board {
|
||||
id: number;
|
||||
name: string;
|
||||
@@ -132,10 +153,11 @@ export interface NotificationsResponse {
|
||||
size: number;
|
||||
}
|
||||
|
||||
// 从 cookie 读取 CSRF token(j13_csrf 为非 HttpOnly,前端可读)
|
||||
// 从 cookie 读取 CSRF token(j13_csrf 为非 HttpOnly,前端可读;生产名为 __Host- 前缀)
|
||||
function getCSRFToken(): string {
|
||||
if (typeof document === "undefined") return "";
|
||||
const match = document.cookie.match(/(?:^|;\s*)j13_csrf=([^;]+)/);
|
||||
const escaped = CSRF_COOKIE.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const match = document.cookie.match(new RegExp(`(?:^|;\\s*)${escaped}=([^;]+)`));
|
||||
return match ? decodeURIComponent(match[1]) : "";
|
||||
}
|
||||
|
||||
@@ -205,70 +227,53 @@ export async function fetchPosts(
|
||||
const params = new URLSearchParams({ page: String(page), size: String(size), sort });
|
||||
if (boardId) params.set("board_id", String(boardId));
|
||||
if (keyword) params.set("keyword", keyword);
|
||||
const res = await fetch(`${API_BASE}/api/posts?${params}`, {
|
||||
cache: "no-store",
|
||||
headers: cookieHeaders(cookieHeader),
|
||||
});
|
||||
const res = await fetch(`${API_BASE}/api/posts?${params}`, ssrInit(cookieHeaders(cookieHeader)));
|
||||
if (!res.ok) throw new Error("获取帖子失败");
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function fetchBoards(): Promise<{ boards: Board[] }> {
|
||||
const res = await fetch(`${API_BASE}/api/boards`, { cache: "no-store" });
|
||||
const res = await fetch(`${API_BASE}/api/boards`, ssrInit());
|
||||
if (!res.ok) throw new Error("获取板块失败");
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function fetchPostDetail(id: string, cookieHeader?: string): Promise<{ post: Post }> {
|
||||
const res = await fetch(`${API_BASE}/api/posts/${id}`, {
|
||||
cache: "no-store",
|
||||
headers: cookieHeaders(cookieHeader),
|
||||
});
|
||||
const res = await fetch(`${API_BASE}/api/posts/${id}`, ssrInit(cookieHeaders(cookieHeader)));
|
||||
if (!res.ok) throw new Error("获取帖子失败");
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function fetchComments(postId: string): Promise<{ comments: Comment[] }> {
|
||||
const res = await fetch(`${API_BASE}/api/posts/${postId}/comments`, { cache: "no-store" });
|
||||
const res = await fetch(`${API_BASE}/api/posts/${postId}/comments`, ssrInit());
|
||||
if (!res.ok) throw new Error("获取评论失败");
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function fetchUserProfile(id: string, page = 1, cookieHeader?: string): Promise<UserProfile> {
|
||||
const params = new URLSearchParams({ page: String(page), size: "20" });
|
||||
const res = await fetch(`${API_BASE}/api/users/${id}?${params}`, {
|
||||
cache: "no-store",
|
||||
headers: cookieHeaders(cookieHeader),
|
||||
});
|
||||
const res = await fetch(`${API_BASE}/api/users/${id}?${params}`, ssrInit(cookieHeaders(cookieHeader)));
|
||||
if (res.status === 404) throw new Error("用户不存在");
|
||||
if (!res.ok) throw new Error("获取用户资料失败");
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// SSR 端获取当前登录用户(layout 直出登录态,避免客户端水合后再切换)
|
||||
export async function fetchMe(cookieHeader?: string): Promise<{ user: User | null }> {
|
||||
const res = await fetch(`${API_BASE}/api/me`, {
|
||||
cache: "no-store",
|
||||
headers: cookieHeaders(cookieHeader),
|
||||
});
|
||||
if (!res.ok) return { user: null };
|
||||
// SSR 端获取当前登录态(用户信息 + 未读通知数合并为一次请求),
|
||||
// 供 layout 直出右上角用户区与铃铛红点,避免客户端水合后再切换
|
||||
export async function fetchMe(cookieHeader?: string): Promise<MeResponse> {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/me`, ssrInit(cookieHeaders(cookieHeader)));
|
||||
if (!res.ok) return { user: null, unread_count: 0 };
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// SSR 端获取未读通知数(Header 铃铛红点直出)
|
||||
export async function fetchUnreadCount(cookieHeader?: string): Promise<number> {
|
||||
const res = await fetch(`${API_BASE}/api/notifications/unread-count`, {
|
||||
cache: "no-store",
|
||||
headers: cookieHeaders(cookieHeader),
|
||||
});
|
||||
if (!res.ok) return 0;
|
||||
const data = await res.json();
|
||||
return data.count || 0;
|
||||
} catch {
|
||||
// 超时/后端不可用:降级游客视图,不阻塞页面渲染
|
||||
return { user: null, unread_count: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchUserComments(id: string, page = 1): Promise<UserCommentsResponse> {
|
||||
const params = new URLSearchParams({ page: String(page), size: "20" });
|
||||
const res = await fetch(`${API_BASE}/api/users/${id}/comments?${params}`, { cache: "no-store" });
|
||||
const res = await fetch(`${API_BASE}/api/users/${id}/comments?${params}`, ssrInit());
|
||||
if (res.status === 404) throw new Error("用户不存在");
|
||||
if (!res.ok) throw new Error("获取评论失败");
|
||||
return res.json();
|
||||
@@ -276,8 +281,8 @@ export async function fetchUserComments(id: string, page = 1): Promise<UserComme
|
||||
|
||||
// ===== 客户端 API 调用(走 rewrite,携带 cookie + CSRF) =====
|
||||
|
||||
// 获取当前登录用户(依赖 OptionalAuth,未登录返回 { user: null })
|
||||
export async function apiMe(): Promise<{ user: User | null }> {
|
||||
// 获取当前登录用户(依赖 OptionalAuth,未登录返回 { user: null, unread_count: 0 })
|
||||
export async function apiMe(): Promise<MeResponse> {
|
||||
const res = await fetch("/api/me", {
|
||||
credentials: "include",
|
||||
cache: "no-store",
|
||||
|
||||
28
frontend/lib/cookies.ts
Normal file
28
frontend/lib/cookies.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
// 认证 cookie 名(前后端必须严格一致):
|
||||
// 生产构建(HTTPS)启用 __Host- 前缀,浏览器强制 Secure + Path=/ + 无 Domain;
|
||||
// 后端按 !DevMode 同步启用(见 backend/service/auth.go ConfigureCookieNames)。
|
||||
const HOST_PREFIX = process.env.NODE_ENV === "production" ? "__Host-" : "";
|
||||
|
||||
export const TOKEN_COOKIE = `${HOST_PREFIX}j13_token`;
|
||||
export const REFRESH_COOKIE = `${HOST_PREFIX}j13_refresh`;
|
||||
export const CSRF_COOKIE = `${HOST_PREFIX}j13_csrf`;
|
||||
|
||||
type CookieReader = {
|
||||
get(name: string): { value: string } | undefined;
|
||||
};
|
||||
|
||||
// 构造转发给后端的最小 Cookie 头:SSR 公开/鉴权接口只需要 access token 来
|
||||
// 识别用户(填充 liked 等状态),不应把 refresh token、主题等无关 cookie 全量转发。
|
||||
// 返回空串表示当前无登录凭据,调用方应省略 Cookie 头。
|
||||
export function authCookieHeader(store: CookieReader): string {
|
||||
const token = store.get(TOKEN_COOKIE);
|
||||
return token ? `${TOKEN_COOKIE}=${token.value}` : "";
|
||||
}
|
||||
|
||||
// 客户端判断浏览器是否可能持有登录态:j13_csrf 非 HttpOnly 可读,
|
||||
// 生命周期与 refresh 一致,适合作为挂载后静默校正的触发信号。
|
||||
export function hasAuthCookieHint(): boolean {
|
||||
if (typeof document === "undefined") return false;
|
||||
const escaped = CSRF_COOKIE.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
return new RegExp(`(?:^|;\\s*)${escaped}=`).test(document.cookie);
|
||||
}
|
||||
@@ -1,20 +1,29 @@
|
||||
import { NextResponse, type NextRequest } from "next/server";
|
||||
import { TOKEN_COOKIE, REFRESH_COOKIE, CSRF_COOKIE } from "@/lib/cookies";
|
||||
|
||||
// SSR 登录态保障:
|
||||
// access token(j13_token,15 分钟)过期后,页面/RSC 请求到达时先在此静默轮转,
|
||||
// 新 cookie 同时①注入本次请求头供 layout 的 /api/me 使用 ②透传给浏览器。
|
||||
// 注意 refresh token 是一次性轮转(旧的立即吊销),而 RSC 预取与真实导航可能
|
||||
// 几乎同时发生,故对同一 refresh token 的并发请求做短窗口去重,避免第二次轮转
|
||||
// 被后端判为重用而清空全部登录态。
|
||||
// refresh token 是一次性轮转(旧的立即吊销),而 RSC 预取与真实导航、甚至多
|
||||
// 运行时实例可能几乎同时发起轮转:
|
||||
// - 同一实例内用 in-flight Promise 去重(只合并进行中的请求,失败不缓存);
|
||||
// - 跨实例的重复请求由后端宽限期兜底(返回同一个新 token 对)。
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL || "http://localhost:3001";
|
||||
// 服务端专用地址;边缘部署(如 Cloudflare Workers)必须通过 BACKEND_URL /
|
||||
// NEXT_PUBLIC_API_URL 显式配置(生产构建不保留 localhost 兜底,避免在边缘
|
||||
// 环境发起必然失败的请求;未配置时放行,由客户端 fetchWithRefresh 兜底)。
|
||||
const API_BASE =
|
||||
process.env.BACKEND_URL ||
|
||||
process.env.NEXT_PUBLIC_API_URL ||
|
||||
(process.env.NODE_ENV === "production" ? "" : "http://localhost:3001");
|
||||
const EXP_SKEW_SECONDS = 30; // 提前 30s 视为过期,规避服务端时钟差
|
||||
const DEDUP_TTL_MS = 10_000;
|
||||
const REFRESH_TIMEOUT_MS = 8000;
|
||||
|
||||
type RefreshResult = { ok: boolean; setCookies: string[] };
|
||||
|
||||
// 模块级去重表:同一 refresh token 在短窗口内只轮转一次
|
||||
const inflight = new Map<string, { at: number; p: Promise<RefreshResult> }>();
|
||||
// 进行中的轮转表:key 为 refresh token 的 SHA-256(不持有明文),
|
||||
// 请求结束即删除,失败结果绝不缓存
|
||||
const inflight = new Map<string, Promise<RefreshResult>>();
|
||||
|
||||
function isAccessTokenExpired(token: string | undefined): boolean {
|
||||
if (!token) return true;
|
||||
@@ -31,28 +40,84 @@ function isAccessTokenExpired(token: string | undefined): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
async function rotateRefreshToken(refreshToken: string, csrf: string): Promise<RefreshResult> {
|
||||
const cached = inflight.get(refreshToken);
|
||||
if (cached && Date.now() - cached.at < DEDUP_TTL_MS) return cached.p;
|
||||
async function hashToken(token: string): Promise<string> {
|
||||
const data = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(token));
|
||||
return Array.from(new Uint8Array(data), (b) => b.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
const p = (async (): Promise<RefreshResult> => {
|
||||
// 从合并形式的 set-cookie 头中拆出多条(部分运行时没有 headers.getSetCookie())。
|
||||
// Expires 属性格式含 ", ",不能直接按逗号切分:只有顶层段是 "非属性名=..."
|
||||
// 才是一条新 cookie 的开始,其余片段拼回上一条。
|
||||
const COOKIE_ATTR_NAMES = new Set([
|
||||
"expires",
|
||||
"max-age",
|
||||
"domain",
|
||||
"path",
|
||||
"samesite",
|
||||
"secure",
|
||||
"httponly",
|
||||
"priority",
|
||||
]);
|
||||
|
||||
function splitCombinedSetCookie(raw: string): string[] {
|
||||
const segments = raw.split(", ");
|
||||
const cookies: string[] = [];
|
||||
for (const seg of segments) {
|
||||
const pair = seg.split(";", 1)[0] ?? "";
|
||||
const eq = pair.indexOf("=");
|
||||
const name = eq > 0 ? pair.slice(0, eq).trim().toLowerCase() : "";
|
||||
if (eq > 0 && name && !COOKIE_ATTR_NAMES.has(name)) {
|
||||
cookies.push(seg);
|
||||
} else if (cookies.length > 0) {
|
||||
cookies[cookies.length - 1] += ", " + seg;
|
||||
}
|
||||
}
|
||||
return cookies;
|
||||
}
|
||||
|
||||
function readSetCookies(res: Response): string[] {
|
||||
const headers = res.headers as unknown as {
|
||||
getSetCookie?: () => string[];
|
||||
};
|
||||
if (typeof headers.getSetCookie === "function") {
|
||||
try {
|
||||
const list = headers.getSetCookie();
|
||||
if (list.length > 0) return list;
|
||||
} catch {
|
||||
// 落到手动解析
|
||||
}
|
||||
}
|
||||
const raw = res.headers.get("set-cookie");
|
||||
return raw ? splitCombinedSetCookie(raw) : [];
|
||||
}
|
||||
|
||||
async function doRotate(refreshToken: string, csrf: string): Promise<RefreshResult> {
|
||||
const res = await fetch(`${API_BASE}/api/auth/refresh`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-CSRF-Token": csrf,
|
||||
Cookie: `j13_refresh=${refreshToken}; j13_csrf=${csrf}`,
|
||||
Cookie: `${REFRESH_COOKIE}=${refreshToken}; ${CSRF_COOKIE}=${csrf}`,
|
||||
},
|
||||
cache: "no-store",
|
||||
signal: AbortSignal.timeout(REFRESH_TIMEOUT_MS),
|
||||
}).catch(() => null);
|
||||
|
||||
if (!res) return { ok: false, setCookies: [] };
|
||||
// 成功=新三枚 cookie;失败(refresh 过期/被吊销)=后端下发的清除指令
|
||||
const setCookies = res.headers.getSetCookie();
|
||||
const setCookies = readSetCookies(res);
|
||||
return { ok: res.ok && setCookies.length > 0, setCookies };
|
||||
})();
|
||||
}
|
||||
|
||||
inflight.set(refreshToken, { at: Date.now(), p });
|
||||
// 同一 refresh token 的并发请求共享同一个进行中的 Promise(完成即删除,不缓存结果)
|
||||
async function rotateRefreshToken(refreshToken: string, csrf: string): Promise<RefreshResult> {
|
||||
const key = await hashToken(refreshToken);
|
||||
const existing = inflight.get(key);
|
||||
if (existing) return existing;
|
||||
const p = doRotate(refreshToken, csrf).finally(() => {
|
||||
inflight.delete(key);
|
||||
});
|
||||
inflight.set(key, p);
|
||||
return p;
|
||||
}
|
||||
|
||||
@@ -65,15 +130,15 @@ function parseCookiePair(setCookie: string): [string, string] | null {
|
||||
}
|
||||
|
||||
export async function middleware(req: NextRequest) {
|
||||
const refreshToken = req.cookies.get("j13_refresh")?.value;
|
||||
const accessToken = req.cookies.get("j13_token")?.value;
|
||||
const refreshToken = req.cookies.get(REFRESH_COOKIE)?.value;
|
||||
const accessToken = req.cookies.get(TOKEN_COOKIE)?.value;
|
||||
|
||||
// 游客,或 access 仍然有效:直接放行
|
||||
if (!refreshToken || !isAccessTokenExpired(accessToken)) {
|
||||
// 游客、access 仍有效、或服务端地址未配置:直接放行
|
||||
if (!refreshToken || !isAccessTokenExpired(accessToken) || !API_BASE) {
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
const csrf = req.cookies.get("j13_csrf")?.value ?? "";
|
||||
const csrf = req.cookies.get(CSRF_COOKIE)?.value ?? "";
|
||||
|
||||
try {
|
||||
const { ok, setCookies } = await rotateRefreshToken(refreshToken, csrf);
|
||||
|
||||
4
frontend/next-env.d.ts
vendored
4
frontend/next-env.d.ts
vendored
@@ -1,7 +1,7 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/types/routes.d.ts";
|
||||
import "./.next/types/root-params.d.ts";
|
||||
import "./.next/dev/types/routes.d.ts";
|
||||
import "./.next/dev/types/root-params.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
|
||||
Reference in New Issue
Block a user