Files
Scoreboard/server/internal/auth/auth.go
T
AskaEth bc8b1ee6c0 feat: M3-M7 全模块落地 + 集成接线
- M3 认证/权限/审计/限流 + 全部 REST 接口
- M4 管理面板(MIUIx + 成绩录入/积分榜下钻/大屏控制)
- M5a 大屏页面(六页 + 明细覆盖层 + 缩放适配)
- M6 WebSocket 实时同步中心
- M7 xlsx 导出 + 赛事包 zip + 备份
- 规则包 YAML 导入导出(差异预览)
- 字体 vendor 子集化(4 套 x2 字重)
- 集成:realtime/export/rules 接入 main+httpapi;backup 子命令
- 修复:中间件包装 ResponseWriter 丢失 Hijacker,导致 WS 握手 501
2026-09-09 19:02:24 +08:00

402 lines
12 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
)
// 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
}
// 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"
buf := make([]byte, n)
if _, err := rand.Read(buf); err != nil {
return "", fmt.Errorf("生成随机密码: %w", err)
}
out := make([]byte, n)
for i, b := range buf {
out[i] = alphabet[int(b)%len(alphabet)]
}
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)
}