开源前加固认证与部署契约:加密随机 JWT、改密事务吊销、登录 CSRF,并禁止生产回落 localhost。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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