P0(9 条全修): - 多通道项目奖牌重复计数:domain 层按 (EventID,CategoryID,EntryID/Subject) 去重, 通道优先级 athlete→team→id - 「只排名不计分」项目(scored:false)不再进奖牌榜,REST/WS/导出口径统一 - aggregate 流水 entry_id 恒 NULL 导致重算后分数翻倍:作废范围改按 event+category 定位 - 已发布成绩仍可被「存草稿」覆盖并清空 rank:服务端 409 STATE_INVALID + 前端禁编辑 - voidResult / 规则导入重算后名次不回写:接住 Ranked 调 applyComputedRanks - 赛事包覆盖导入可能「旧项目已删、新项目没成」:先改名成功再清理,失败回滚 - 导入赛事包可覆盖正在运行的赛事目录:name==当前项目直接 400 - Console spawn 失败后守护器永久卡死:清 child/pid + 调度重启 + stop 超时兜底 - /ws 快照向非 admin 下发 admin-only 配置:按角色裁剪 settings P1(计分口径 / 契约 / 现场): - standings.tieBreak 三处接线;profiles[].recordBonusMode 生效;破纪录校验 RecordEligible 与 entry 归属;破纪录登记校验已发布成绩 - export.loadRanked 与主路径对齐;ListBackups reason 解析;CLI backup 前缀修正 (原来只增不减会撑爆磁盘);单位积分明细/导出统一权重折算;停用单位展示 - 规则导入原子化(重算并入同一事务)+ 返回 compatible/affectedEventCount/ recalculatedLedgerRows;登录审计 user_id 修正 - WS 策略性断开用 Close(code,reason);HelloMessage 补 page/instanceId; 实例注册表清理;settings/单位/项目写操作补 change_log 触发大屏刷新 - 大屏:全量快照不再被版本守卫丢弃;个人积分榜刷新;公告返回页/轮播;行数截断; REST 竞态;1008 停止重连;握手超时;时区补 Z;401 仅 AUTH_REQUIRED 才登出 - 管理端:published 行禁编辑;saveDraft 补 unit(1:06.33 显示);前端预览改用服务端 points;空成绩行不再恒「已修改」;修订详情按 id 拉取;切项目请求序号与 dirty 保护; 发布后刷新项目状态;NAV/路由按角色过滤;recorder 不再打 admin-only 接口 - Console:大屏窗口销毁后重建;大屏 URL 复用 host 归一化;backup.bat 改纯 ASCII (已在 Meowhome 实跑验证) 自检:服务端 gofmt/build/vet 通过、12 包测试全绿;前端 vue-tsc + build 通过、 node --test 24/24;console check-syntax 12 文件通过。
486 lines
15 KiB
Go
486 lines
15 KiB
Go
package auth
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"crypto/subtle"
|
|
"database/sql"
|
|
"encoding/base64"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"golang.org/x/crypto/bcrypt"
|
|
)
|
|
|
|
// Cookie 名称与会话默认值(见 devdocs/08-安全与可靠性 第 1 节)。
|
|
const (
|
|
CookieSession = "scoreboard_session"
|
|
CookieCSRF = "csrf_token"
|
|
|
|
// DefaultSessionTTL 是会话有效期:12h。
|
|
DefaultSessionTTL = 12 * time.Hour
|
|
// DefaultBcryptCost 满足文档「bcrypt cost ≥ 12」。
|
|
DefaultBcryptCost = 12
|
|
// DefaultLoginPerMinute 登录限流:5 次/分钟/IP。
|
|
DefaultLoginPerMinute = 5
|
|
// DefaultWritePerMinute 写操作限流:30 次/分钟/用户。
|
|
DefaultWritePerMinute = 30
|
|
// 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 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
|
|
sessionTTL time.Duration
|
|
bcryptCost int
|
|
secureCookies bool
|
|
now func() time.Time
|
|
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 := opts.SessionTTL
|
|
if ttl <= 0 {
|
|
ttl = DefaultSessionTTL
|
|
}
|
|
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
|
|
}
|
|
return &Service{
|
|
db: db,
|
|
log: logger,
|
|
sessionTTL: ttl,
|
|
bcryptCost: cost,
|
|
secureCookies: opts.Secure,
|
|
now: now,
|
|
loginLimiter: NewLimiter(loginLimit, time.Minute, now),
|
|
writeLimiter: NewLimiter(writeLimit, time.Minute, now),
|
|
}
|
|
}
|
|
|
|
// SessionTTL 返回会话有效期。
|
|
func (s *Service) SessionTTL() time.Duration { return s.sessionTTL }
|
|
|
|
// 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
|
|
role string
|
|
)
|
|
err := s.db.QueryRowContext(ctx,
|
|
`SELECT s.id, s.user_id, s.expires_at, COALESCE(s.ip, ''), COALESCE(s.user_agent, ''),
|
|
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,
|
|
&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)
|
|
}
|
|
|
|
exp, err := parseDBTime(expiresAt)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
sess.ExpiresAt = exp
|
|
u.Role = ParseRole(role)
|
|
|
|
_, _ = s.db.ExecContext(ctx, `UPDATE sessions SET last_seen_at = ? WHERE id = ?`,
|
|
s.now().UTC().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) {
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: CookieSession,
|
|
Value: token,
|
|
Path: "/",
|
|
MaxAge: int(s.sessionTTL.Seconds()),
|
|
HttpOnly: true,
|
|
Secure: s.secureCookies,
|
|
SameSite: http.SameSiteLaxMode,
|
|
})
|
|
}
|
|
|
|
// ClearSessionCookie 删除会话 Cookie。
|
|
func (s *Service) ClearSessionCookie(w http.ResponseWriter) {
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: CookieSession,
|
|
Value: "",
|
|
Path: "/",
|
|
MaxAge: -1,
|
|
HttpOnly: true,
|
|
Secure: s.secureCookies,
|
|
SameSite: http.SameSiteLaxMode,
|
|
})
|
|
}
|
|
|
|
// SetCSRFCookie 写入可被前端读取的 CSRF Cookie(双提交 Cookie 方案)。
|
|
func (s *Service) SetCSRFCookie(w http.ResponseWriter, token string) {
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: CookieCSRF,
|
|
Value: token,
|
|
Path: "/",
|
|
MaxAge: int(s.sessionTTL.Seconds()),
|
|
HttpOnly: false,
|
|
Secure: s.secureCookies,
|
|
SameSite: http.SameSiteLaxMode,
|
|
})
|
|
}
|
|
|
|
// ClearCSRFCookie 删除 CSRF Cookie。
|
|
func (s *Service) ClearCSRFCookie(w http.ResponseWriter) {
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: CookieCSRF,
|
|
Value: "",
|
|
Path: "/",
|
|
MaxAge: -1,
|
|
HttpOnly: false,
|
|
Secure: s.secureCookies,
|
|
SameSite: http.SameSiteLaxMode,
|
|
})
|
|
}
|
|
|
|
// 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)
|
|
}
|