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
This commit is contained in:
1 parent
d0e3b93e76
commit
bc8b1ee6c0
145 files changed
+25159
-321
No files matched your search
@@ -0,0 +1,401 @@
|
||||
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)
|
||||
}
|
||||
Reference in new issue
Block a user