Files
AskaEth 6841cf49aa fix(p2): 按审查清单修 103 条「建议修」+ 奖牌榜改名 + 两个守卫(dev_0.2.0)
## 奖牌榜改名(主人要求)
「团体奖牌榜」→「单位奖牌榜」,界面 / 大屏渲染页 / 导出件(工作表名与
下载文件名 `单位奖牌明细.xlsx`)/ 用户文档 全部统一;`DISPLAY_PAGES` 是
大屏控制页与大屏页的共同来源,改一处两边同时生效。旧名保留为别名,
老链接与老配置(`display.pages`)照常有效。真 Chromium 验证:14 个管理端
路由 + 7 个基础数据分区 0 报错,大屏页标题已是「单位奖牌榜」。

## P2 清单(123 条,103 条标「建议修」)
按 A/B/C/D 分组、以「文件所有权互斥」分 10 个代理并行推进,另有独立复核
代理逐条核对:61 条声称已修 / 55 条证实 / 4 条部分 / 0 条不成立 / 0 垃圾。

主要交付(按风险从高到低):
- 写入侧校验:update 路径补必填与 TrimSpace、模板 rules 重复 rank、
  「小项不存在」的四种回答、`defaultProfileId` 外键、decodeJSON 的 8MB
  上限与 413(A01–A06、D02、D03 批量上限 500)
- 危险操作:一键恢复加互斥(第二个请求当场 409)、恢复超时不再说
  「未执行」、`ConfirmDialog` pending 时锁死 ESC/遮罩/关闭
- 认证:改密与撤会话进同一事务、滑动续期写库失败不再假装成功、
  CSRF Cookie 丢失自动补发、密码长度按字符(原按字节)、
  反代 403 只在可信代理透传原 Host 时采信 `X-Forwarded-Host`(全等比较)、
  过期会话定期清理、登出补审计、登录限流可配
- 数据可见性:导出「已发布未结算」行改为 `名次 — / 积分 — / 状态未结算`、
  单位积分明细成绩列与其它导出件逐字一致、空单位也打合计行、
  基础数据包命中「已彻底删除的单位」整行跳过并报界面路径、
  报名导入换单位给明确提示、勾选报名有冲突不再假报成功
- 导入/导出:赛事包上限 512MB(**它是唯一带字体/图片的包**,字体上限
  20MB)而 multipart 内存档位独立钉在 16MB、工作簿表名尾随空格、
  导出件路由与文件名契约补全
- 前端:一次写请求只生成一个 requestId(幂等缓存此前从未命中)、
  基础数据包补 `X-Request-Id`、示例数据模式禁写扩到撤销发布/存草稿、
  访客 401 不再是裸错误、模板 `{{else if}}` 不再静默吞内容
- Console:按设计退出(切赛事/恢复)不再判成崩溃、旧数据目录读不出来
  会记日志、渲染层日志按上限截断、`service.js` 补 17 例测试

## 测试
服务端用例 610 → 674(13 包全绿,新增老库跨九条迁移升级、panic 形状、
429 落 interceptions、三态对照、avg_mode 分母、会话失效、请求体上限等)
前端 449 → 531(vue-tsc + build 通过);Console 28 → 54(19 个文件)。
另修复一条会让整包挂死 600s 的用例(测试主协程堵在 client.Do,defer
轮不到执行 → 改为可取消 + 探针超时,改坏也 7.5s 快速失败)。

## 两个新守卫(防止再出现「组件 setup 抛异常」这类盲区)
- `web/scripts/check-declaration-order.mjs` 已进测试命令:沿 watch/
  watchEffect 的 const 依赖链查 TDZ(正是「运动员页整块空白」的根因)
- `web/scripts/smoke-admin.mjs`(`pnpm smoke`):真 Chromium 点遍 14 个
  管理端路由 + 7 个基础数据分区 + 大屏页,任何 console 错误即失败

## 文档
- `docs/升级说明.md` 新增 dev_0.2.0 一节(三件升级必知:改名、审计前缀
  与历史记录、密码口径;现场变化;赛事包上限;反代与 CSRF)
- 使用手册 / 常见问题 / 部署指南 / 上线检查清单按实现同步
2026-09-11 23:46:59 +08:00

773 lines
31 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package auth
import (
"context"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"database/sql"
"encoding/base64"
"encoding/hex"
"errors"
"fmt"
"log/slog"
"net/http"
"strconv"
"strings"
"sync/atomic"
"time"
"golang.org/x/crypto/bcrypt"
)
// Cookie 名称与会话默认值(见 devdocs/08-安全与可靠性 第 1 节)。
const (
CookieSession = "scoreboard_session"
CookieCSRF = "csrf_token"
// DefaultSessionTTL 是登录有效期的默认值:24h。
// 原来是 12h:现场前一晚 20:00 登录,次日早上 8:00 整被踢出登录,
// 而那正是比赛进行中的时间——这正是本项需求要修的。
DefaultSessionTTL = 24 * time.Hour
// DefaultSessionHours 与 DefaultSessionTTL 同值,供以「小时」为单位的配置读写共用。
DefaultSessionHours = 24
// MinSessionHours / MaxSessionHours 是面板可配的登录有效期范围:1 小时 ~ 720 小时(30 天)。
MinSessionHours = 1
MaxSessionHours = 720
// SessionHoursKey 是系统库 settings 里存登录有效期的键(机器级配置,与赛事级 settings 不共用)。
SessionHoursKey = "security.session_hours"
// SessionHoursSourceKey 记录 SessionHoursKey 是谁写的:面板写过的值一定是操作员的明确选择,
// 不能被下面的历史占位值规则改写。
SessionHoursSourceKey = "security.session_hours_source"
// SessionHoursSourcePanel 是面板写入时登记的来源标记。
SessionHoursSourcePanel = "panel"
// legacySessionHoursSeed 是 0005 迁移种下的历史占位值。
// 在本项改动之前,没有任何代码读过 security.session_hours,所以库里还留着 12
// 只可能是「没人配过」;它按「未配置」处理,用新的默认 24,否则新装的库会永远停在 12。
legacySessionHoursSeed = "12"
// renewThreshold 是滑动续期的写入节流窗口:剩余寿命不足「有效期 - 1 分钟」时才回写 expires_at。
// 每个请求都续期会让 sessions 变成热点写表(现场几十个客户端轮询),
// 而少顺延这 1 分钟对「会不会掉线」没有任何影响。
renewThreshold = time.Minute
// touchInterval 是 last_seen_at 的回写节流窗口:没有续期时,活动时间最快每分钟写一次。
// 一次页面加载会打若干个请求,逐个回写纯属放大写压力;last_seen_at 只用于排障,分钟级足够。
touchInterval = time.Minute
// sessionPurgeInterval 是「清理过期会话行」的最小间隔。
// sessions 只增不减(每次登录一行,过期行与 token 哈希永久留存),需要一个清理动作;
// 单实例部署不值得为它常开一个 goroutine(还要在 main 里管生命周期),
// 于是挂在「查找会话」这条登录后必经的路径上按间隔做一次:只要有人在线,清理就不会停。
sessionPurgeInterval = time.Hour
// DefaultBcryptCost 满足文档「bcrypt cost ≥ 12」。
DefaultBcryptCost = 12
// DefaultLoginPerMinute 登录限流:5 次/分钟/IP。防爆破用,别跟着写限流一起放大。
DefaultLoginPerMinute = 5
// LoginRateKey 是系统库 settings 里存登录限流的键。0005 迁移种下 5,
// 但在本项改动之前全仓没有一行代码读它——现场按「系统设置」改这个键不起作用,
// 限流实际上只由编译期常量决定,属死配置。
LoginRateKey = "security.login_rate_per_minute"
// MinLoginPerMinute / MaxLoginPerMinute 是系统库那个键的可接受范围。
// 上限卡在 60:这是防爆破的闸门,配成几千等于把它关掉;越界或非法值按「未配置」回落默认。
MinLoginPerMinute = 1
MaxLoginPerMinute = 60
// DefaultWritePerMinute 写操作限流:120 次/分钟/用户。
//
// 原来定 30,现场一次批量导入 / 批量删除跑完就会撞上(第 7 次起全是 429),
// 表现为「操作到一半被限流」。单人现场录入的突发量本来就大,而且失败的请求也计数,
// 留 4 倍余量;登录限流另算,不受影响。
DefaultWritePerMinute = 120
// MaxPasswordBytes 是 bcrypt 的输入上限;超过会被静默截断,必须显式拒绝。
MaxPasswordBytes = 72
)
// dummyBcryptHash 是固定 cost=12 的哈希,用于「用户不存在」时也做一次同价比较,
// 避免用响应时间探测用户名是否存在。
const dummyBcryptHash = "$2a$12$m3MLFvKxRAgD/5cksQn9wuFpm/NmV0nyAWAJkZMCyT7s7oAoHWX8q"
// 认证相关哨兵错误,调用方据此映射 REST 错误码。
var (
ErrInvalidCredentials = errors.New("auth: 用户名或密码错误")
ErrUserInactive = errors.New("auth: 用户已停用")
ErrSessionNotFound = errors.New("auth: 会话不存在或已过期")
ErrRateLimited = errors.New("auth: 触发限流")
)
// User 是登录用户;guest 不落库,用 RoleGuest 表示。
type User struct {
ID int64
Username string
DisplayName string
Role Role
Active bool
MustChangePassword bool
LastLoginAt string
}
// Session 是一次登录产生的服务端会话。
//
// IP / UserAgent 目前除本包外无人读(面板还没有「在线会话」视图),但保留写入:
// 排查「这条会话是哪个 IP / 哪台设备开的」时它们才是唯一线索,
// 而留存本身有上界——Lookup 里的过期行清理会把超过会话寿命的行连同这两列一起删掉(见 purgeExpiredSessions)。
type Session struct {
ID string
UserID int64
ExpiresAt time.Time
IP string
UserAgent string
}
// Options 是认证服务的可调参数;零值即文档默认。
type Options struct {
Logger *slog.Logger
// SessionTTL 显式指定登录有效期(测试 / 嵌入用)。优先级最低的那一档默认值:
// 只要系统库 settings 里有有效的 security.session_hours(面板写的就是它),就以面板为准;
// 这里只在系统库里没有配置时兜底(例如测试用的空库)。
//
// 注意:生产构造点(main.go)不传这个字段,也没有对应的启动参数 / 配置文件字段——
// devdocs/05 里「面板 > 启动参数 > 内置默认」的中间那一档目前只存在于本字段,
// 实际只有测试在用(P2-C13)。要把它变成真的启动参数得先在 main.go 加 flag。
SessionTTL time.Duration
BcryptCost int
// LoginPerMinute 显式指定登录限流(测试 / 嵌入用);优先级同 SessionTTL:
// 系统库 settings 里的 security.login_rate_per_minute 优先,本字段只在库里没有有效值时生效。
LoginPerMinute int
WritePerMinute int
// Secure 为 true 时 Cookie 带 Secure 标记(HTTPS 部署)。
Secure bool
// Now 便于测试注入时间;默认 time.Now。
Now func() time.Time
}
// Service 封装用户与会话的读写、密码校验与限流。
type Service struct {
db *sql.DB
log *slog.Logger
bcryptCost int
secureCookies bool
now func() time.Time
// ttl 是当前生效的登录有效期;面板可以随时改(SetSessionTTL),
// 所以每个请求都会读它,必须用原子量而不是普通字段。
ttl atomic.Int64
// lastPurge 是上一次清理过期会话行的时刻(Unix 纳秒,0 = 还没清理过)。
// 请求会并发走到 Lookup,节流判断与「谁来做这次清理」都必须原子地一次完成。
lastPurge atomic.Int64
loginLimiter *Limiter
writeLimiter *Limiter
}
// New 构建认证服务;db 为系统库(data/system.db)。
func New(db *sql.DB, opts Options) *Service {
logger := opts.Logger
if logger == nil {
logger = slog.Default()
}
// 优先级:面板(系统库设置)> 启动参数 / 配置文件 > 内置默认。
// 面板排在最前是为了不出现「改了面板没反应」:只要系统库里有一个有效值,
// 它就是这个进程的有效期,别处的默认值一律让位。
ttl, source := DefaultSessionTTL, "内置默认"
if dbTTL, ok := sessionTTLFromSettings(db); ok {
ttl, source = dbTTL, "系统设置"
} else if opts.SessionTTL > 0 {
ttl, source = opts.SessionTTL, "启动参数"
}
cost := opts.BcryptCost
if cost <= 0 {
cost = DefaultBcryptCost
}
now := opts.Now
if now == nil {
now = time.Now
}
loginLimit, loginSource := DefaultLoginPerMinute, "内置默认"
// 与登录有效期同一条优先级:系统库设置 > 启动参数 > 内置默认。
// 0005 迁移早就种下了 security.login_rate_per_minute,但直到现在才有人读它。
if v, ok := loginRateFromSettings(db); ok {
loginLimit, loginSource = v, "系统设置"
} else if opts.LoginPerMinute > 0 {
loginLimit, loginSource = opts.LoginPerMinute, "启动参数"
}
writeLimit := opts.WritePerMinute
if writeLimit == 0 {
writeLimit = DefaultWritePerMinute
}
logger.Info("登录有效期", "hours", ttl.Hours(), "source", source,
"滑动续期", true, "节流窗口", renewThreshold.String())
logger.Info("登录限流", "perMinute", loginLimit, "source", loginSource)
s := &Service{
db: db,
log: logger,
bcryptCost: cost,
secureCookies: opts.Secure,
now: now,
loginLimiter: NewLimiter(loginLimit, time.Minute, now),
writeLimiter: NewLimiter(writeLimit, time.Minute, now),
}
s.ttl.Store(int64(ttl))
return s
}
// SessionTTL 返回当前生效的登录有效期。
func (s *Service) SessionTTL() time.Duration { return time.Duration(s.ttl.Load()) }
// SetSessionTTL 更新登录有效期。只对之后创建的会话与之后的续期生效:
// 已存在会话的 expires_at 一律不动——改设置不该把正在录入的人踢下线。
func (s *Service) SetSessionTTL(d time.Duration) {
if d <= 0 {
return
}
s.ttl.Store(int64(d))
}
// ParseSessionHours 解析登录有效期(小时);非法或超出 1~720 一律判为无效。
func ParseSessionHours(raw string) (int, bool) {
hours, err := strconv.Atoi(strings.TrimSpace(raw))
if err != nil || hours < MinSessionHours || hours > MaxSessionHours {
return 0, false
}
return hours, true
}
// EffectiveSessionHours 算出实际生效的小时数。
// 规则:面板写过的值原样生效(含操作员明确选的 12);迁移种下的历史占位值 12
// 因为从来没有代码读过,按「未配置」处理,回落到新默认 24;缺键或非法同样是默认值。
func EffectiveSessionHours(values map[string]string) int {
raw := strings.TrimSpace(values[SessionHoursKey])
if hours, ok := ParseSessionHours(raw); ok {
if strings.TrimSpace(values[SessionHoursSourceKey]) != "" || raw != legacySessionHoursSeed {
return hours
}
}
return DefaultSessionHours
}
// sessionTTLFromSettings 从系统库 settings 读登录有效期;没有有效配置(缺键或非法)时 ok=false,
// 由调用方回落到启动参数 / 内置默认。数据库不可用不当作 fatal:认证要能带着默认值起来。
func sessionTTLFromSettings(db *sql.DB) (time.Duration, bool) {
if db == nil {
return 0, false
}
values := make(map[string]string, 2)
rows, err := db.Query(`SELECT key, value FROM settings WHERE key IN (?, ?)`,
SessionHoursKey, SessionHoursSourceKey)
if err != nil {
slog.Warn("读取登录有效期失败,本次用默认值", "err", err, "hours", DefaultSessionHours)
return 0, false
}
defer func() { _ = rows.Close() }()
for rows.Next() {
var k, v string
if err := rows.Scan(&k, &v); err != nil {
slog.Warn("解析登录有效期失败,本次用默认值", "err", err, "hours", DefaultSessionHours)
return 0, false
}
values[k] = v
}
if err := rows.Err(); err != nil {
slog.Warn("读取登录有效期失败,本次用默认值", "err", err, "hours", DefaultSessionHours)
return 0, false
}
if _, ok := values[SessionHoursKey]; !ok {
return 0, false
}
return time.Duration(EffectiveSessionHours(values)) * time.Hour, true
}
// loginRateFromSettings 从系统库 settings 读登录限流(次/分钟/IP)。
// 缺键、非法或越界一律 ok=false,由调用方回落到启动参数 / 内置默认——
// 这个键是防爆破的闸门,配错了宁可用默认值,也不能因为库里写了个 0 就把闸门拿掉。
func loginRateFromSettings(db *sql.DB) (int, bool) {
if db == nil {
return 0, false
}
var raw string
switch err := db.QueryRow(`SELECT value FROM settings WHERE key = ?`, LoginRateKey).Scan(&raw); {
case errors.Is(err, sql.ErrNoRows):
return 0, false
case err != nil:
slog.Warn("读取登录限流失败,本次用默认值", "err", err, "perMinute", DefaultLoginPerMinute)
return 0, false
}
n, err := strconv.Atoi(strings.TrimSpace(raw))
if err != nil || n < MinLoginPerMinute || n > MaxLoginPerMinute {
slog.Warn("登录限流配置非法,本次用默认值",
"value", raw, "perMinute", DefaultLoginPerMinute,
"min", MinLoginPerMinute, "max", MaxLoginPerMinute)
return 0, false
}
return n, true
}
// AllowLogin 按 IP 限流登录。
func (s *Service) AllowLogin(ip string) bool { return s.loginLimiter.Allow(ip) }
// AllowWrite 按用户限流写操作。
func (s *Service) AllowWrite(userID int64) bool {
return s.writeLimiter.Allow(fmt.Sprintf("u:%d", userID))
}
// EnsureDefaultAdmin 在 users 表为空时创建默认管理员,返回随机初始密码。
// 密码只在此处打印一次,数据库里只存 bcrypt 哈希。
func (s *Service) EnsureDefaultAdmin(ctx context.Context) (username, password string, created bool, err error) {
var count int
if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM users`).Scan(&count); err != nil {
return "", "", false, fmt.Errorf("统计用户: %w", err)
}
if count > 0 {
return "", "", false, nil
}
password, err = randomPassword(16)
if err != nil {
return "", "", false, err
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), s.bcryptCost)
if err != nil {
return "", "", false, fmt.Errorf("生成密码哈希: %w", err)
}
username = "admin"
_, err = s.db.ExecContext(ctx,
`INSERT INTO users (username, password_hash, display_name, role, active, must_change_password)
VALUES (?, ?, ?, 'admin', 1, 1)`,
username, string(hash), "系统管理员")
if err != nil {
return "", "", false, fmt.Errorf("创建默认管理员: %w", err)
}
return username, password, true, nil
}
// RandomPassword 生成 n 位随机密码,供首次初始化和 CLI 重置使用。
func RandomPassword(n int) (string, error) { return randomPassword(n) }
// SetPassword 重置用户密码并作废其全部会话;mustChange 表示下次登录后强制改密。
//
// 「改密」与「作废会话」必须同成同败,因此两条语句进同一个事务(见 withUserSessionRevoke)。
func (s *Service) SetPassword(ctx context.Context, username, password string, mustChange bool) error {
if password == "" {
return fmt.Errorf("密码不能为空")
}
if len(password) > MaxPasswordBytes {
return fmt.Errorf("密码不能超过 %d 字节", MaxPasswordBytes)
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), s.bcryptCost)
if err != nil {
return fmt.Errorf("生成密码哈希: %w", err)
}
must := 0
if mustChange {
must = 1
}
return s.withUserSessionRevoke(ctx, username, func(tx *sql.Tx) error {
res, err := tx.ExecContext(ctx,
`UPDATE users SET password_hash = ?, must_change_password = ?, updated_at = datetime('now') WHERE username = ?`,
string(hash), must, username)
if err != nil {
return fmt.Errorf("更新密码: %w", err)
}
if n, err := res.RowsAffected(); err == nil && n == 0 {
return fmt.Errorf("用户 %s 不存在", username)
}
return nil
})
}
// SetUsername 修改用户名(供本机管理入口免登录调用),并作废该用户全部会话。
// 与 SetPassword 同一口径:改名与撤会话要么都成、要么都不成。
func (s *Service) SetUsername(ctx context.Context, oldUsername, newUsername string) error {
if newUsername == "" {
return fmt.Errorf("新用户名不能为空")
}
return s.withUserSessionRevoke(ctx, newUsername, func(tx *sql.Tx) error {
res, err := tx.ExecContext(ctx,
`UPDATE users SET username = ?, updated_at = datetime('now') WHERE username = ?`,
newUsername, oldUsername)
if err != nil {
return fmt.Errorf("更新用户名: %w", err)
}
if n, err := res.RowsAffected(); err == nil && n == 0 {
return fmt.Errorf("用户 %s 不存在", oldUsername)
}
return nil
})
}
// withUserSessionRevoke 在一个事务里执行 fn(改用户行)并删掉该用户(改名后用 newName 定位)的全部会话行。
//
// 为什么要事务:改密 / 改名与「作废会话」是同一个安全动作的两半,中间态是危险的——
// 撤会话失败而密码已改 = 「密码改了,但旧会话(可能正是被盗用的那个)仍然全有效」,
// 用户以为已经止血、实际没有;反过来先撤会话再改密失败 = 人全被踢下线却没改成密码。
// 事务保证外部只看得到两种结果:全做了,或什么都没做(此时旧密码与旧会话都还在,语义自洽)。
//
// system.db 的连接池上限是 1(store.Open),事务在提交前独占该连接,两条语句不会被别的请求插进来。
func (s *Service) withUserSessionRevoke(ctx context.Context, username string, fn func(*sql.Tx) error) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("开启事务: %w", err)
}
defer func() { _ = tx.Rollback() }()
if err := fn(tx); err != nil {
return err
}
// 用子查询定位 user_id:用户名唯一,省掉一次「读回 id」的往返(读了也是同一事务内的快照)。
if _, err := tx.ExecContext(ctx,
`DELETE FROM sessions WHERE user_id = (SELECT id FROM users WHERE username = ?)`, username); err != nil {
return fmt.Errorf("作废会话: %w", err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("提交事务: %w", err)
}
return nil
}
// VerifyPassword 校验指定用户的原密码,不创建会话(供「修改密码」等场景使用)。
func (s *Service) VerifyPassword(ctx context.Context, userID int64, password string) error {
var hash string
if err := s.db.QueryRowContext(ctx,
`SELECT password_hash FROM users WHERE id = ? AND active = 1`, userID).Scan(&hash); err != nil {
return ErrInvalidCredentials
}
if bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) != nil {
return ErrInvalidCredentials
}
return nil
}
// Authenticate 校验用户名密码并创建会话,返回用户与不落库的明文 token。
func (s *Service) Authenticate(ctx context.Context, username, password, ip, userAgent string) (*User, string, error) {
if !s.AllowLogin(ip) {
return nil, "", ErrRateLimited
}
var (
u User
hash string
role string
)
err := s.db.QueryRowContext(ctx,
`SELECT id, username, password_hash, display_name, role, active, must_change_password,
COALESCE(last_login_at, '')
FROM users WHERE username = ?`, strings.TrimSpace(username)).
Scan(&u.ID, &u.Username, &hash, &u.DisplayName, &role, &u.Active, &u.MustChangePassword, &u.LastLoginAt)
switch {
case errors.Is(err, sql.ErrNoRows):
// 用户名不存在也走一次 bcrypt,避免用响应时间探测用户是否存在。
_ = bcrypt.CompareHashAndPassword([]byte(dummyBcryptHash), []byte(password))
return nil, "", ErrInvalidCredentials
case err != nil:
return nil, "", fmt.Errorf("查询用户: %w", err)
}
if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)); err != nil {
return nil, "", ErrInvalidCredentials
}
if !u.Active {
return nil, "", ErrUserInactive
}
u.Role = ParseRole(role)
token, _, err := s.createSession(ctx, u.ID, ip, userAgent)
if err != nil {
return nil, "", err
}
if _, err := s.db.ExecContext(ctx,
`UPDATE users SET last_login_at = datetime('now'), updated_at = datetime('now') WHERE id = ?`, u.ID); err != nil {
return nil, "", fmt.Errorf("更新登录时间: %w", err)
}
u.LastLoginAt = s.now().UTC().Format(time.DateTime)
s.loginLimiter.Reset(ip)
return &u, token, nil
}
// Lookup 按明文 token 查会话与用户;过期或停用都返回 ErrSessionNotFound。
func (s *Service) Lookup(ctx context.Context, token string) (*User, *Session, error) {
if strings.TrimSpace(token) == "" {
return nil, nil, ErrSessionNotFound
}
hash := hashToken(token)
var (
u User
sess Session
expiresAt string
lastSeenRaw string
role string
)
err := s.db.QueryRowContext(ctx,
`SELECT s.id, s.user_id, s.expires_at, COALESCE(s.ip, ''), COALESCE(s.user_agent, ''),
COALESCE(s.last_seen_at, ''),
u.id, u.username, u.display_name, u.role, u.active, u.must_change_password,
COALESCE(u.last_login_at, '')
FROM sessions s
JOIN users u ON u.id = s.user_id
WHERE s.token_hash = ? AND s.expires_at > ? AND u.active = 1`,
hash, s.now().UTC().Format(time.DateTime)).
Scan(&sess.ID, &sess.UserID, &expiresAt, &sess.IP, &sess.UserAgent, &lastSeenRaw,
&u.ID, &u.Username, &u.DisplayName, &role, &u.Active, &u.MustChangePassword, &u.LastLoginAt)
switch {
case errors.Is(err, sql.ErrNoRows):
return nil, nil, ErrSessionNotFound
case err != nil:
return nil, nil, fmt.Errorf("查询会话: %w", err)
}
now := s.now().UTC()
exp, err := parseDBTime(expiresAt)
if err != nil {
return nil, nil, err
}
sess.ExpiresAt = exp
u.Role = ParseRole(role)
// 过期会话行顺手清一次(节流见 sessionPurgeInterval)。放在续期之前:
// 续期那条分支会提前 return,放后面就永远轮不到它。
s.purgeExpiredSessions(ctx, now)
// 滑动续期:会话只要还在有效期内被使用,就把到期时间顺延到 now+有效期,
// 而不是「登录满 N 小时必死」——现场前一晚登录、比赛当天一直在用的人不该掉线。
//
// 写入节流:只有剩余寿命已不足「有效期 - renewThreshold(1 分钟)」时才回写 expires_at。
// 否则每点一次按钮、每次轮询都要写一遍 sessions,把单连接 sqlite 变成热点写表;
// 少顺延这 1 分钟不会让任何人提前掉线(前一次续期至少还剩一个有效期)。
if ttl := s.SessionTTL(); ttl > renewThreshold && exp.Sub(now) < ttl-renewThreshold {
renewed := now.Add(ttl)
if _, err := s.db.ExecContext(ctx, `UPDATE sessions SET last_seen_at = ?, expires_at = ? WHERE id = ?`,
now.Format(time.DateTime), renewed.Format(time.DateTime), sess.ID); err != nil {
// 写库失败时不能只顺延内存里的到期时间:/api/auth/me 会拿 sess.ExpiresAt 重发 Cookie,
// 于是浏览器被告知「还有一整天」,库里却还是旧时刻——用户会在毫无征兆的情况下被登出,
// 排查时「服务端说没过期、库里已经过期」两边对不上。
// 这里的取舍是「放弃本次续期」:本次请求照常放行(会话此刻仍然有效),
// 库与内存保持同一个到期时间,等下一次请求再试。
s.log.Warn("会话续期写库失败,本次不续期", "err", err, "sessionId", sess.ID)
return &u, &sess, nil
}
sess.ExpiresAt = renewed
return &u, &sess, nil
}
// 没到续期点时也不是每个请求都写:last_seen_at 最快每 touchInterval 回写一次。
// 一个页面加载会打七八个请求,逐个写一遍纯属放大写压力,而「上次活动时间」精确到分钟就够用。
// 这条写失败无所谓:last_seen_at 只用于排障,不影响「会话还活着」的判定。
lastSeen, err := parseDBTime(lastSeenRaw)
if err != nil || now.Sub(lastSeen) >= touchInterval {
_, _ = s.db.ExecContext(ctx, `UPDATE sessions SET last_seen_at = ? WHERE id = ?`,
now.Format(time.DateTime), sess.ID)
}
return &u, &sess, nil
}
// purgeExpiredSessions 删掉已过期的会话行。
//
// sessions 只增不减:每次登录留一行,过期行连同 token 哈希永久躺在库里(含 ip / user_agent),
// 这是无界增长也是个人信息留存。迁移里早就有 idx_sessions_expires_at 索引,就是为这件事准备的。
// 节流到 sessionPurgeInterval 一次,且只在「有人带着会话访问」时触发——
// 没人登录就没有新会话行,也就不需要清理。
// 失败不吞:写 WARN,并把节流时间戳清掉,让下一次请求立刻重试。
func (s *Service) purgeExpiredSessions(ctx context.Context, now time.Time) {
last := s.lastPurge.Load()
if last != 0 && now.Sub(time.Unix(0, last)) < sessionPurgeInterval {
return
}
// CAS 只有一个请求能拿到「本次清理」的资格,其余请求直接跳过(并发下只删一次)。
if !s.lastPurge.CompareAndSwap(last, now.UnixNano()) {
return
}
res, err := s.db.ExecContext(ctx,
`DELETE FROM sessions WHERE expires_at <= ?`, now.UTC().Format(time.DateTime))
if err != nil {
s.lastPurge.Store(0)
s.log.Warn("清理过期会话失败", "err", err)
return
}
if n, err := res.RowsAffected(); err == nil && n > 0 {
s.log.Info("清理过期会话", "rows", n)
}
}
// Logout 删除会话(服务端强制下线)。
func (s *Service) Logout(ctx context.Context, token string) error {
if strings.TrimSpace(token) == "" {
return nil
}
_, err := s.db.ExecContext(ctx, `DELETE FROM sessions WHERE token_hash = ?`, hashToken(token))
if err != nil {
return fmt.Errorf("删除会话: %w", err)
}
return nil
}
// RevokeUserSessions 强制某用户全部下线。
func (s *Service) RevokeUserSessions(ctx context.Context, userID int64) error {
_, err := s.db.ExecContext(ctx, `DELETE FROM sessions WHERE user_id = ?`, userID)
return err
}
// SessionActive 判定一条会话现在是否仍然可用:会话行还在、未过期、用户未停用。
//
// 判定口径与 Lookup 完全一致(同样是 JOIN users 并比对 expires_at),但不做滑动续期、
// 也不回写 last_seen_at:它只回答「这条会话还在不在」,供 /ws 长连接周期性复查。
// 撤销类操作(登出、改密、管理员重置密码、停用用户)都会删掉会话行或停用用户,
// 因此这里查不到即为失效。查询失败按错误返回,由调用方决定断开还是保留连接。
func (s *Service) SessionActive(ctx context.Context, sessionID string) (bool, error) {
if strings.TrimSpace(sessionID) == "" {
return false, nil
}
var one int
err := s.db.QueryRowContext(ctx,
`SELECT 1 FROM sessions s
JOIN users u ON u.id = s.user_id
WHERE s.id = ? AND s.expires_at > ? AND u.active = 1`,
sessionID, s.now().UTC().Format(time.DateTime)).Scan(&one)
switch {
case errors.Is(err, sql.ErrNoRows):
return false, nil
case err != nil:
return false, fmt.Errorf("复查会话: %w", err)
default:
return true, nil
}
}
func (s *Service) createSession(ctx context.Context, userID int64, ip, userAgent string) (string, *Session, error) {
token, err := randomToken(32)
if err != nil {
return "", nil, err
}
id, err := randomToken(16)
if err != nil {
return "", nil, err
}
expiresAt := s.now().UTC().Add(s.SessionTTL())
_, err = s.db.ExecContext(ctx,
`INSERT INTO sessions (id, user_id, token_hash, expires_at, ip, user_agent, last_seen_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
id, userID, hashToken(token), expiresAt.Format(time.DateTime), ip, userAgent, s.now().UTC().Format(time.DateTime))
if err != nil {
return "", nil, fmt.Errorf("创建会话: %w", err)
}
return token, &Session{ID: id, UserID: userID, ExpiresAt: expiresAt, IP: ip, UserAgent: userAgent}, nil
}
// ---------- Cookie 与 CSRF ----------
// SetSessionCookie 写入 HttpOnly + SameSite=Lax 的会话 Cookie。
func (s *Service) SetSessionCookie(w http.ResponseWriter, token string) {
setSessionCookie(w, s.secureCookies, token, int(s.SessionTTL().Seconds()))
}
// RefreshSessionCookies 按会话到期时间重发会话与 CSRF Cookie。
//
// 滑动续期之后必须重发:Cookie 的 Max-Age 是浏览器端的硬期限,
// 不跟着续期走的话,浏览器会在「登录时刻 + 有效期」处把 Cookie 丢掉,
// 服务端续得再久也留不住人。CSRF 沿用原值(不轮换),避免和正在飞行的写请求打架。
// 到期时间已过时不重发——调用方不该给一个已经失效的会话续 Cookie。
func (s *Service) RefreshSessionCookies(w http.ResponseWriter, sessionToken, csrfToken string, expiresAt time.Time) {
life := expiresAt.Sub(s.now())
if life <= 0 || sessionToken == "" {
return
}
seconds := int(life.Seconds())
setSessionCookie(w, s.secureCookies, sessionToken, seconds)
if csrfToken != "" {
setCSRFCookie(w, s.secureCookies, csrfToken, seconds)
}
}
func setSessionCookie(w http.ResponseWriter, secure bool, token string, maxAge int) {
http.SetCookie(w, &http.Cookie{
Name: CookieSession,
Value: token,
Path: "/",
MaxAge: maxAge,
HttpOnly: true,
Secure: secure,
SameSite: http.SameSiteLaxMode,
})
}
// ClearSessionCookie 删除会话 Cookie。
func (s *Service) ClearSessionCookie(w http.ResponseWriter) {
setSessionCookie(w, s.secureCookies, "", -1)
}
// SetCSRFCookie 写入可被前端读取的 CSRF Cookie(双提交 Cookie 方案)。
func (s *Service) SetCSRFCookie(w http.ResponseWriter, token string) {
setCSRFCookie(w, s.secureCookies, token, int(s.SessionTTL().Seconds()))
}
func setCSRFCookie(w http.ResponseWriter, secure bool, token string, maxAge int) {
http.SetCookie(w, &http.Cookie{
Name: CookieCSRF,
Value: token,
Path: "/",
MaxAge: maxAge,
HttpOnly: false,
Secure: secure,
SameSite: http.SameSiteLaxMode,
})
}
// ClearCSRFCookie 删除 CSRF Cookie。
func (s *Service) ClearCSRFCookie(w http.ResponseWriter) {
setCSRFCookie(w, s.secureCookies, "", -1)
}
// NewCSRFToken 生成新的 CSRF Token。
func NewCSRFToken() (string, error) { return randomToken(32) }
// ValidCSRF 比较请求头与 Cookie 里的 CSRF Token(常数时间比较)。
func ValidCSRF(header, cookie string) bool {
if header == "" || cookie == "" {
return false
}
return subtle.ConstantTimeCompare([]byte(header), []byte(cookie)) == 1
}
// ---------- 辅助 ----------
func hashToken(token string) string {
sum := sha256.Sum256([]byte(token))
return hex.EncodeToString(sum[:])
}
func randomToken(n int) (string, error) {
buf := make([]byte, n)
if _, err := rand.Read(buf); err != nil {
return "", fmt.Errorf("生成随机数: %w", err)
}
return base64.RawURLEncoding.EncodeToString(buf), nil
}
// randomPassword 生成去掉易混淆字符的随机密码。
// 用拒绝采样消除取模偏差,避免某些字符出现概率更高。
func randomPassword(n int) (string, error) {
const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789"
// 只接受小于 256-(256%len) 的字节,落在尾部的字节直接丢弃重取。
const limit = 256 - (256 % len(alphabet))
buf := make([]byte, n)
out := make([]byte, 0, n)
for len(out) < n {
if _, err := rand.Read(buf); err != nil {
return "", fmt.Errorf("生成随机密码: %w", err)
}
for _, b := range buf {
if int(b) >= limit {
continue
}
out = append(out, alphabet[int(b)%len(alphabet)])
if len(out) == n {
break
}
}
}
return string(out), nil
}
// parseDBTime 解析迁移里 datetime('now') 写入的 UTC 时间。
func parseDBTime(v string) (time.Time, error) {
for _, layout := range []string{time.DateTime, "2006-01-02T15:04:05Z07:00", time.RFC3339} {
if t, err := time.Parse(layout, v); err == nil {
return t, nil
}
}
return time.Time{}, fmt.Errorf("auth: 无法解析时间 %q", v)
}