Files
Scoreboard/server/internal/auth/auth.go
T
AskaEth 45edfbeb71 fix: P0 计分根因 + 奖牌结算/奖牌通道 + 身份口径改回证件号 + 19 条 P1(dev_0.1.8)
## P0:双通道项目重复发布跳分(45→85→收敛)
results.rank 一列同时充当「个人名次输出」「手工名次输入」「榜单数据源」,
团体手工通道把个人名次当团体名次又按团体表发一遍分。
- 新增 channel_ranks(每个计分通道各自持久化名次,发布时全量重建)
  与 channel_manual_ranks(按单位录入的团体手工名次,故意不挂通道外键,
  避免规则包重导时级联删掉录入数据)
- results.rank 语义收窄为「只表示个人名次」,只由 scope=athlete 通道写回;
  团体手工名次的来源改为 TeamRanks 优先、团体报名项兜底,
  个人报名项的名次绝不进团体通道
- writeLedgerTx 内重建 channel_ranks;buildRanked / export.loadRanked /
  realtime.loadRanked 三份同源 SQL 一起改读新表
- 回归:发布两次流水与名次逐字节一致(domain + httpapi 各一条,含变异验证)

## 奖牌结算开关与奖牌通道(P1(5),新功能)
- events.award_medals + 通道 count_to_medals / count_to_team_medals
  (迁移默认 1,升级不改变既有榜单)
- 单位榜入选 = 小项结算奖牌 && 通道发奖牌 && 前三名 &&(团体项目 || 个人奖牌并入单位)
- 个人榜只取个人通道;金牌榜与积分榜同源同数据、按通道过滤独立计算
- 前端:小项「该项启用奖牌结算」、通道「发奖牌/个人奖牌并入单位金牌榜」、
  录入页新增「团体名次」区块(含新接口 GET/PUT .../team-ranks)

## 身份口径改回证件号(P1(15),推翻第三十六轮)
- 匹配顺序恢复 证件号 → 号码布 → 姓名+单位;新增 athleteByIDCard
- 同表重复证件号 = 同一人报多个组别(notices 提示,不报错);
  只有「同证件号 + 同小项同组别」才是真重复(conflicts[].duplicate)
- 补齐后端缺失但前端/文档已在依赖的三处契约

## 其余 P1
- 安全:会话撤销断开 ws、实时层按角色裁剪、整库快照限流(含 hello 路径,
  被限流的请求挂起并由心跳兜底补发,不丢刷新)
- 错误分级:手填名次非正数 / gender / 组别年龄 / 导出不存在的小项与单位 /
  积分模板 awardRanks → 400 或 404,不再 500
- 算分一致性:破纪录加分看得失状态;批量退赛·恢复·移出、作废成绩、
  报名导入改状态一律无条件重算推榜(不受延迟结算约束,与实时模式逐字节等价)
- 配置改动触发重算:通道 / 小项参数 / 积分模板
- 升级自愈:channel_ranks 空而已有已发布成绩时,启动时一次性全量重算
  (服务对外监听之前完成,避免升级后奖牌榜全 0)
- 示例数据模式下破坏性操作被阻断,不再假成功
- Console:修复「只剩一块屏」后屏幕监听器被永久摘掉
- 大屏模板引擎:嵌套深度与体积安全阀,任何输入都不抛异常

## 文档
- docs/:使用手册(奖牌开关、奖牌通道、团体名次、枚举列留空口径)、
  常见问题(奖牌排查、升级后奖牌榜为 0、历史重放已知限制)、
  部署指南、上线检查清单
- 版本 dev_0.1.7 → dev_0.1.8(console/package.json 同步)

自检:服务端 13 包 ×2 全绿;前端 vue-tsc + 449 例 ×2 + pnpm build;
Console 35 例 + 语法检查。
2026-09-11 21:47:07 +08:00

650 lines
24 KiB
Go
Raw 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
// DefaultBcryptCost 满足文档「bcrypt cost ≥ 12」。
DefaultBcryptCost = 12
// DefaultLoginPerMinute 登录限流:5 次/分钟/IP。防爆破用,别跟着写限流一起放大。
DefaultLoginPerMinute = 5
// 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 是一次登录产生的服务端会话。
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(面板写的就是它),就以面板为准;
// 这里只在系统库里没有配置时兜底(例如测试用的空库)。
SessionTTL time.Duration
BcryptCost int
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
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 := opts.LoginPerMinute
if loginLimit == 0 {
loginLimit = DefaultLoginPerMinute
}
writeLimit := opts.WritePerMinute
if writeLimit == 0 {
writeLimit = DefaultWritePerMinute
}
logger.Info("登录有效期", "hours", ttl.Hours(), "source", source,
"滑动续期", true, "节流窗口", renewThreshold.String())
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
}
// 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 表示下次登录后强制改密。
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
}
res, err := s.db.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)
}
var id int64
if err := s.db.QueryRowContext(ctx, `SELECT id FROM users WHERE username = ?`, username).Scan(&id); err != nil {
return fmt.Errorf("查询用户: %w", err)
}
return s.RevokeUserSessions(ctx, id)
}
// SetUsername 修改用户名(供本机管理入口免登录调用),并作废该用户全部会话。
func (s *Service) SetUsername(ctx context.Context, oldUsername, newUsername string) error {
if newUsername == "" {
return fmt.Errorf("新用户名不能为空")
}
res, err := s.db.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)
}
var id int64
if err := s.db.QueryRowContext(ctx, `SELECT id FROM users WHERE username = ?`, newUsername).Scan(&id); err != nil {
return fmt.Errorf("查询用户: %w", err)
}
return s.RevokeUserSessions(ctx, id)
}
// 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)
// 滑动续期:会话只要还在有效期内被使用,就把到期时间顺延到 now+有效期,
// 而不是「登录满 N 小时必死」——现场前一晚登录、比赛当天一直在用的人不该掉线。
//
// 写入节流:只有剩余寿命已不足「有效期 - renewThreshold(1 分钟)」时才回写 expires_at。
// 否则每点一次按钮、每次轮询都要写一遍 sessions,把单连接 sqlite 变成热点写表;
// 少顺延这 1 分钟不会让任何人提前掉线(前一次续期至少还剩一个有效期)。
if ttl := s.SessionTTL(); ttl > renewThreshold && exp.Sub(now) < ttl-renewThreshold {
renewed := now.Add(ttl)
_, _ = s.db.ExecContext(ctx, `UPDATE sessions SET last_seen_at = ?, expires_at = ? WHERE id = ?`,
now.Format(time.DateTime), renewed.Format(time.DateTime), sess.ID)
sess.ExpiresAt = renewed
return &u, &sess, nil
}
// 没到续期点时也不是每个请求都写:last_seen_at 最快每 touchInterval 回写一次。
// 一个页面加载会打七八个请求,逐个写一遍纯属放大写压力,而「上次活动时间」精确到分钟就够用。
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
}
// 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)
}