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:
@@ -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,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 论坛板块
|
||||
|
||||
Reference in New Issue
Block a user