Files
Scoreboard/server/internal/auth/auth.go
T
AskaEth 631c8dd8e1 feat: 8 条功能 + 身份口径改为号码布为准(含批量退赛/移出、基础数据导入导出、登录有效期)
用户给出的 8 条功能 + 一条口径变更,全部带真机/真浏览器验证:

① 录入页姓名后直接显示号码布(原「号码」列保留)
② 录入页批次进度条:`第 2 批 已录 5/8 · 3 条有错`,录完变绿;已录判据复用
   entryGuards.judgeEntryRow(与发布防呆同源,不会出现"进度条说录完、发布说缺 8 条")
⑦ 小项列表双击 → 跳到该小项的组别(编辑/删除按钮不误触)
③ 赛事开关 `standings.live_on_publish`(默认开=保持原行为):关闭后分批发布只上屏成绩,
   名次/流水/榜单一起等该组录完再结算 —— 代理实读代码后纠正了我的前提:
   奖牌榜读的是 results.rank(buildRanked)而非流水汇总,只延迟流水会出现
   「有金牌但 0 分」。另提供 POST /api/results/settle 人工兜底 + GET /api/results/settlement 未结算清单
④ 组别「就地更新」PUT /api/events/{id}/categories/{categoryId}:有报名数据时也能改名/改年龄/
   改性别/改权重(旧路径是整表替换,一改就被 409 拦下);改名不动报名/成绩/流水(真机逐字节比对);
   权重真的变了才触发该组重算(容差 1e-9)
④辅 运动员列表多选+全选,POST /api/athletes/batch 一次请求完成整批停用/彻底删除(逐条回报,
   dryRun 预览走同一段 SQL 但不提交、不写审计)——这才是绕开限流的正解
新增 报名项批量:POST /api/entries/batch(scratch/restore/remove)。退赛只动 registered、
   跳过 dq/dns 并说明原因;移出是物理删除,必须显式清 score_ledger(该表 entry_id 是
   ON DELETE SET NULL,不显式删会留下"人没了分还在"的孤儿流水,验证基线 orphan=0);
   退赛同时清 results.rank(否则出现"0 分还挂着金牌")
⑤ GET /api/export/entries.xlsx 报名名单导出(13 列含批次,状态中文与录入页逐字一致)
⑥ 基础数据包:GET /api/export/basedata.xlsx + POST /api/export/basedata/import
   (四张表、表头只有一份定义、表头行自动定位、列缺席≠写空、按编码 upsert、绝不删除、
   行级中文错误带真实 Excel 行号、枚举走既有校验、新增单位一定是启用)
⑧ 登录有效期面板可配:系统设置新页签,1–720 小时,默认 12h → 24h;滑动续期 +
   Cookie 同步刷新(只服务端续期是假的,浏览器侧的 Max-Age 到点仍会丢 Cookie)
身份口径:号码布为唯一标识、证件号退出匹配(号码布不同=同名的两个人,新建并单独报告);
   同一份表重复证件号不再报错;两个不同的人共用证件号仍报行级错误(唯一索引保留)

其它对齐:分项成绩表草稿行不再消失(成绩列「分数未录入」、名次/积分 `—`,没成绩的行同占位);
大屏实时赛况单栏铺满(留白 370→78px)、字号上限 56→96px、≥7 行自动拆左右两块;
删除报名项/退赛/权重等改动都会重算并推榜单。

测试:服务端 13 包(连跑两遍)、前端 335 例、vue-tsc + build、Console 18 文件 —— 全绿。
文档:README、docs/ 四份(含 debug/使用说明 副本)、devdocs 05/08/04/13。
版本号 dev_0.1.5 → dev_0.1.6。
2026-09-11 17:14:02 +08:00

624 lines
23 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
}
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)
}