audit-2026-09-21.md 里的后端条目落地。每条都配了回归守卫,并逐条做过变异验证 (注入原 bug → 确认按正确理由变红 → 还原 → 变绿)。 安全 - CSRF 只加同源 Origin/Referer 守卫(csrf.go),不动模板 - 会话 cookie 的 Secure 按请求判断;两步验证两闸(loginGuard + 验证码一次性) - 跳转收口到 safeInternalPath;clientIP 只认 XFF 最后一段 - GPX 按属主圈定且查 RowsAffected;下载名走 downloadDisposition - 上传口统一 MaxBytesReader;落盘后的失败路径清掉半成品 数据一致性 - 区域判定两端各过一遍再 AND(apiTrack 与 movementsWhere 对齐) - Filter.Verdict 全域一致(下推到 visitsWhere);聚合刷新必须返回 error - 附属数据改挂坐标键,Go 与 SQL 两套表达式逐位一致 - 脏备份的伪时间/越界坐标不再进领域模型;rows 循环统一 drainRows - 合并与导出:writeRawRow 上抛错误、悬空引用清理、原生列补全 性能 - placeCols 相关子查询带 dataset_id 前导列;findSourceArchive 先按大小筛 - 批量勾选改 ANY($3::bigint[]);fillupCache 加上限;同一查询不再查两遍 另:新增「全新库」冒烟测试 —— 空库跑完 Migrate 再用应用侧读查询真打一遍, 迁移块排到依赖表之前这种错只有新库会炸,老库毫无症状。
129 lines
3.3 KiB
Go
129 lines
3.3 KiB
Go
// Package auth 的登录失败短锁。
|
|
package auth
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"math/big"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// Guard 拦登录爆破:按用户名累计失败次数,到阈值后按指数退避短暂锁住。
|
|
//
|
|
// 键只用用户名、**不带 IP**——clientIP 优先信 X-Forwarded-For 的第一段,
|
|
// 而那是客户端可伪造的,拿它当键就等于「每换一个请求头换一把新锁」。
|
|
// 本站只有一个账号(启动时创建,没有注册入口),用户名是稳定的,
|
|
// 所以它才是可靠的爆破计数键。
|
|
//
|
|
// 状态在内存里:站点是单进程部署,重启即清空,代价可接受。
|
|
type Guard struct {
|
|
mu sync.Mutex
|
|
items map[string]entry
|
|
now func() time.Time // 测试注入用
|
|
}
|
|
|
|
type entry struct {
|
|
fails int
|
|
until time.Time // 锁到什么时候
|
|
seen time.Time
|
|
}
|
|
|
|
const (
|
|
// guardFreeTries 是阈值:前几次失败不锁,正常手滑不该被罚。
|
|
guardFreeTries = 5
|
|
// guardBaseLock 是刚过阈值时的锁定时长,之后每次失败翻倍。
|
|
guardBaseLock = 2 * time.Second
|
|
guardMaxLock = 15 * time.Minute
|
|
// guardMaxKeys 与 guardIdleTTL 只管内存:有人拿随机用户名刷接口时,
|
|
// 记录不能无限长下去。
|
|
guardMaxKeys = 1024
|
|
guardIdleTTL = time.Hour
|
|
)
|
|
|
|
// NewGuard 建一个空白的失败计数器。
|
|
func NewGuard() *Guard {
|
|
return &Guard{items: map[string]entry{}, now: time.Now}
|
|
}
|
|
|
|
// RetryAfter 返回该键还要等多久才能再试;0 表示可以试。
|
|
func (g *Guard) RetryAfter(key string) time.Duration {
|
|
g.mu.Lock()
|
|
defer g.mu.Unlock()
|
|
return g.retryAfterLocked(key)
|
|
}
|
|
|
|
func (g *Guard) retryAfterLocked(key string) time.Duration {
|
|
e, ok := g.items[key]
|
|
if !ok {
|
|
return 0
|
|
}
|
|
if d := e.until.Sub(g.now()); d > 0 {
|
|
return d
|
|
}
|
|
return 0
|
|
}
|
|
|
|
// Fail 记一次失败,返回这次之后需要等待的时长(0 表示还没到阈值)。
|
|
func (g *Guard) Fail(key string) time.Duration {
|
|
g.mu.Lock()
|
|
defer g.mu.Unlock()
|
|
g.pruneLocked()
|
|
e := g.items[key]
|
|
e.fails++
|
|
e.seen = g.now()
|
|
if e.fails >= guardFreeTries {
|
|
// 第 5 次失败锁 2 秒,第 6 次 4 秒……封顶 15 分钟
|
|
lock := guardBaseLock << uint(e.fails-guardFreeTries)
|
|
if lock > guardMaxLock || lock <= 0 {
|
|
lock = guardMaxLock
|
|
}
|
|
e.until = g.now().Add(lock)
|
|
}
|
|
g.items[key] = e
|
|
return g.retryAfterLocked(key)
|
|
}
|
|
|
|
// Succeed 登录成功就清掉计数:口令是对的,之前的失败不再累积到下次。
|
|
func (g *Guard) Succeed(key string) {
|
|
g.mu.Lock()
|
|
delete(g.items, key)
|
|
g.mu.Unlock()
|
|
}
|
|
|
|
// Reset 清空全部计数(测试与本地排查用)。
|
|
func (g *Guard) Reset() {
|
|
g.mu.Lock()
|
|
g.items = map[string]entry{}
|
|
g.mu.Unlock()
|
|
}
|
|
|
|
func (g *Guard) pruneLocked() {
|
|
now := g.now()
|
|
for k, v := range g.items {
|
|
if now.Sub(v.seen) > guardIdleTTL {
|
|
delete(g.items, k)
|
|
}
|
|
}
|
|
for k := range g.items {
|
|
if len(g.items) < guardMaxKeys {
|
|
break
|
|
}
|
|
delete(g.items, k)
|
|
}
|
|
}
|
|
|
|
// RandomPassword 生成 n 位随机口令,字符集去掉了容易看错的 0/O/1/I/L。
|
|
// 用于「没配 ROND_ADMIN_PASSWORD 时别再写死出厂口令」。
|
|
func RandomPassword(n int) (string, error) {
|
|
const alphabet = "23456789ABCDEFGHJKMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz"
|
|
var b = make([]byte, n)
|
|
for i := range b {
|
|
idx, err := rand.Int(rand.Reader, big.NewInt(int64(len(alphabet))))
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
b[i] = alphabet[idx.Int64()]
|
|
}
|
|
return string(b), nil
|
|
}
|