mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-28 11:16:37 +08:00
317 lines
11 KiB
Go
317 lines
11 KiB
Go
package service
|
||
|
||
import (
|
||
"context"
|
||
"crypto/sha256"
|
||
"encoding/hex"
|
||
"fmt"
|
||
"html"
|
||
"strings"
|
||
"time"
|
||
|
||
"go.uber.org/zap"
|
||
|
||
"github.com/truewhile/MeBox/internal/model"
|
||
"github.com/truewhile/MeBox/internal/repository"
|
||
)
|
||
|
||
// DeviceService implements device/session tracking and the two decoupled
|
||
// enforcement policies requested by the operator:
|
||
//
|
||
// ① 防共享: too many concurrent playbacks / logged-in clients disables the
|
||
// account immediately; fingerprint mismatch is warning-based and disables
|
||
// the account after the configured warning threshold.
|
||
// ② Mgo 保号规则: admins define one or more keep rules; a sweep deletes
|
||
// accounts only when none of the enabled keep rules match.
|
||
//
|
||
// Safeguards: admin / protected accounts are never auto disabled or deleted;
|
||
// a Telegram notification is sent before a destructive action; every policy
|
||
// defaults to OFF.
|
||
type DeviceService struct {
|
||
log *zap.Logger
|
||
repo *repository.Container
|
||
sessions *SessionTrackerService
|
||
|
||
// notifyUser sends a Telegram message to the local user (resolved to their
|
||
// Telegram binding). Wired by the bot service; nil disables notifications.
|
||
notifyUser func(ctx context.Context, userID, text string)
|
||
|
||
// notifyAdmin sends a Telegram message to all admin accounts.
|
||
// Wired by the bot service; nil disables notifications.
|
||
notifyAdmin func(ctx context.Context, text string)
|
||
}
|
||
|
||
// NewDeviceService constructs a DeviceService.
|
||
func NewDeviceService(log *zap.Logger, repo *repository.Container) *DeviceService {
|
||
return &DeviceService{log: log, repo: repo}
|
||
}
|
||
|
||
// SetNotifier wires the per-user Telegram notification callback.
|
||
func (s *DeviceService) SetNotifier(fn func(ctx context.Context, userID, text string)) {
|
||
s.notifyUser = fn
|
||
}
|
||
|
||
// SetAdminNotifier wires the admin-broadcast Telegram notification callback.
|
||
func (s *DeviceService) SetAdminNotifier(fn func(ctx context.Context, text string)) {
|
||
s.notifyAdmin = fn
|
||
}
|
||
|
||
func (s *DeviceService) SetSessionTracker(tracker *SessionTrackerService) {
|
||
s.sessions = tracker
|
||
}
|
||
|
||
// fingerprint derives a stable terminal hash from the device name. Client/app
|
||
// names are deliberately ignored so one phone/TV/PC using multiple apps is
|
||
// still counted as one terminal device; Client remains a login channel label.
|
||
func fingerprint(client, deviceName string) string {
|
||
terminal := strings.ToLower(strings.TrimSpace(deviceName))
|
||
if terminal == "" {
|
||
terminal = strings.ToLower(strings.TrimSpace(client))
|
||
}
|
||
sum := sha256.Sum256([]byte(terminal))
|
||
return hex.EncodeToString(sum[:])[:16]
|
||
}
|
||
|
||
// isProtected reports whether a user must never be auto disabled/deleted.
|
||
// Admins are always protected; the earliest admin (default admin) too.
|
||
func (s *DeviceService) isProtected(ctx context.Context, u *model.User) bool {
|
||
return UserIsProtectedAccount(ctx, s.repo, u)
|
||
}
|
||
|
||
// RecordLogin records (or refreshes) a login channel at authentication time and
|
||
// runs the terminal-device + fingerprint anti-share checks. It is safe to call
|
||
// on every Emby/Jellyfin AuthenticateByName request.
|
||
func (s *DeviceService) RecordLogin(ctx context.Context, userID, deviceID, deviceName, client, ip string) {
|
||
if userID == "" {
|
||
return
|
||
}
|
||
username := ""
|
||
if u, _ := s.repo.User.FindByID(ctx, userID); u != nil {
|
||
username = u.Username
|
||
}
|
||
if deviceID == "" {
|
||
// Fall back to a fingerprint-derived id so headless clients still count.
|
||
deviceID = "fp-" + fingerprint(client, deviceName)
|
||
}
|
||
if s.sessions != nil {
|
||
s.sessions.RecordLogin(ctx, userID, username, deviceID, deviceName, client, ip)
|
||
}
|
||
fp := fingerprint(client, deviceName)
|
||
now := s.now()
|
||
|
||
existing, _ := s.findTerminalDevice(ctx, userID, deviceID, fp)
|
||
mismatch := false
|
||
if existing == nil {
|
||
_ = s.repo.UserDevice.Create(ctx, &model.UserDevice{
|
||
UserID: userID,
|
||
DeviceID: deviceID,
|
||
DeviceName: deviceName,
|
||
Client: client,
|
||
Fingerprint: fp,
|
||
LastIP: ip,
|
||
FirstSeenAt: now,
|
||
LastSeenAt: now,
|
||
})
|
||
// 新终端首次登录才通知:已知设备重复登录不刷屏。
|
||
s.notify(ctx, userID, fmt.Sprintf("🔔 新设备登录:<b>%s</b>\n如果这不是你本人,请到「个人资料 → 我的设备」踢下线并修改密码。", deviceLabel(deviceName, client)))
|
||
} else {
|
||
if existing.Fingerprint != "" && existing.Fingerprint != fp {
|
||
mismatch = true
|
||
}
|
||
existing.DeviceID = deviceID
|
||
existing.DeviceName = deviceName
|
||
existing.Client = client
|
||
existing.Fingerprint = fp
|
||
existing.LastIP = ip
|
||
existing.LastSeenAt = now
|
||
existing.Kicked = false
|
||
_ = s.repo.UserDevice.Save(ctx, existing)
|
||
s.deleteStaleTerminalDeviceRows(ctx, userID, fp, existing.ID)
|
||
}
|
||
|
||
cfg := loadBotConfig(ctx, s.repo)
|
||
if !cfg.AntiShareEnabled {
|
||
return
|
||
}
|
||
|
||
if mismatch {
|
||
s.registerFingerprintWarning(ctx, userID, fmt.Sprintf("设备指纹变更(设备:%s)", deviceLabel(deviceName, client)), cfg)
|
||
return
|
||
}
|
||
since := now.Add(-time.Duration(cfg.ClientActiveDays) * 24 * time.Hour)
|
||
if n, err := s.repo.UserDevice.CountActiveClients(ctx, userID, since); err == nil && int(n) > cfg.MaxLoggedClients {
|
||
s.disableForPolicy(ctx, userID, fmt.Sprintf("同时登录终端设备 %d 台,超过上限 %d 台", n, cfg.MaxLoggedClients))
|
||
}
|
||
}
|
||
|
||
// RecordPlayback marks a device as actively playing and runs the concurrent
|
||
// playback anti-share check. Call from playback-progress reporting.
|
||
func (s *DeviceService) RecordPlayback(ctx context.Context, userID, deviceID, deviceName, client string) {
|
||
if userID == "" {
|
||
return
|
||
}
|
||
username := ""
|
||
if u, _ := s.repo.User.FindByID(ctx, userID); u != nil {
|
||
username = u.Username
|
||
}
|
||
if deviceID == "" {
|
||
deviceID = "fp-" + fingerprint(client, deviceName)
|
||
}
|
||
if s.sessions != nil {
|
||
s.sessions.RecordPlayback(ctx, userID, username, deviceID, deviceName, client, "", "", 0, 0, false)
|
||
}
|
||
now := s.now()
|
||
fp := fingerprint(client, deviceName)
|
||
existing, _ := s.findTerminalDevice(ctx, userID, deviceID, fp)
|
||
if existing == nil {
|
||
existing = &model.UserDevice{
|
||
UserID: userID,
|
||
DeviceID: deviceID,
|
||
DeviceName: deviceName,
|
||
Client: client,
|
||
Fingerprint: fp,
|
||
FirstSeenAt: now,
|
||
LastSeenAt: now,
|
||
}
|
||
existing.LastPlayAt = &now
|
||
_ = s.repo.UserDevice.Create(ctx, existing)
|
||
} else {
|
||
existing.DeviceID = deviceID
|
||
existing.DeviceName = deviceName
|
||
existing.Client = client
|
||
existing.Fingerprint = fp
|
||
existing.LastSeenAt = now
|
||
existing.LastPlayAt = &now
|
||
_ = s.repo.UserDevice.Save(ctx, existing)
|
||
s.deleteStaleTerminalDeviceRows(ctx, userID, fp, existing.ID)
|
||
}
|
||
|
||
cfg := loadBotConfig(ctx, s.repo)
|
||
if !cfg.AntiShareEnabled {
|
||
return
|
||
}
|
||
since := now.Add(-time.Duration(cfg.PlayWindowSeconds) * time.Second)
|
||
if n, err := s.repo.UserDevice.CountConcurrentPlaying(ctx, userID, since); err == nil && int(n) > cfg.MaxConcurrentPlay {
|
||
s.disableForPolicy(ctx, userID, fmt.Sprintf("同时播放终端设备 %d 台,超过上限 %d 台", n, cfg.MaxConcurrentPlay))
|
||
}
|
||
}
|
||
|
||
func (s *DeviceService) findTerminalDevice(ctx context.Context, userID, deviceID, fp string) (*model.UserDevice, error) {
|
||
existing, err := s.repo.UserDevice.Find(ctx, userID, deviceID)
|
||
if err != nil || existing != nil {
|
||
return existing, err
|
||
}
|
||
if strings.TrimSpace(fp) == "" {
|
||
return nil, nil
|
||
}
|
||
return s.repo.UserDevice.FindByFingerprint(ctx, userID, fp)
|
||
}
|
||
|
||
func (s *DeviceService) deleteStaleTerminalDeviceRows(ctx context.Context, userID, fp, keepID string) {
|
||
if strings.TrimSpace(fp) == "" || strings.TrimSpace(keepID) == "" {
|
||
return
|
||
}
|
||
if err := s.repo.UserDevice.DeleteByFingerprintExcept(ctx, userID, fp, keepID); err != nil && s.log != nil {
|
||
s.log.Warn("device terminal cleanup failed",
|
||
zap.String("user_id", userID),
|
||
zap.String("fingerprint", fp),
|
||
zap.Error(err))
|
||
}
|
||
}
|
||
|
||
// registerFingerprintWarning increments the fingerprint warning counter for a
|
||
// user and disables the account once warnings exceed the threshold. Violations
|
||
// are debounced so a single burst counts at most once per minute.
|
||
func (s *DeviceService) registerFingerprintWarning(ctx context.Context, userID, reason string, cfg botConfig) {
|
||
u, err := s.repo.User.FindByID(ctx, userID)
|
||
if err != nil || u == nil {
|
||
return
|
||
}
|
||
if s.isProtected(ctx, u) {
|
||
s.log.Info("anti-share: skipping protected account", zap.String("user", u.Username), zap.String("reason", reason))
|
||
return
|
||
}
|
||
now := s.now()
|
||
if u.LastShareWarnAt != nil && now.Sub(*u.LastShareWarnAt) < time.Minute {
|
||
return // debounce burst
|
||
}
|
||
|
||
warnings := u.ShareWarnings + 1
|
||
if warnings > cfg.WarnThreshold {
|
||
s.disableForPolicy(ctx, userID, fmt.Sprintf("多次设备指纹异常:%s", reason))
|
||
s.log.Warn("anti-share: disabling account after fingerprint warnings",
|
||
zap.String("user", u.Username), zap.Int("warnings", u.ShareWarnings), zap.String("reason", reason))
|
||
return
|
||
}
|
||
_ = s.repo.User.UpdateFields(ctx, userID, map[string]any{
|
||
"share_warnings": warnings,
|
||
"last_share_warn_at": &now,
|
||
})
|
||
left := cfg.WarnThreshold + 1 - warnings
|
||
warnText := fmt.Sprintf("⚠️ 账号 <b>%s</b> 触发设备指纹警告:%s\n这是第 <b>%d</b> 次警告,再异常 <b>%d</b> 次将禁用账号。请到「个人资料 → 我的设备」踢下线异常设备。",
|
||
html.EscapeString(u.Username), html.EscapeString(reason), warnings, left)
|
||
s.notify(ctx, userID, warnText)
|
||
s.notifyAdminMsg(ctx, warnText)
|
||
s.log.Info("anti-share: warning issued", zap.String("user", u.Username), zap.Int("warnings", warnings), zap.String("reason", reason))
|
||
}
|
||
|
||
func (s *DeviceService) disableForPolicy(ctx context.Context, userID, reason string) {
|
||
u, err := s.repo.User.FindByID(ctx, userID)
|
||
if err != nil || u == nil || !u.IsActive {
|
||
return
|
||
}
|
||
if s.isProtected(ctx, u) {
|
||
s.log.Info("device policy: skipping protected account", zap.String("user", u.Username), zap.String("reason", reason))
|
||
return
|
||
}
|
||
now := s.now()
|
||
_ = s.repo.User.UpdateFields(ctx, userID, map[string]any{
|
||
"is_active": false,
|
||
"last_share_warn_at": &now,
|
||
})
|
||
_ = s.repo.UserDevice.SetKickedByUser(ctx, userID, true)
|
||
disableText := fmt.Sprintf("⛔️ 账号 <b>%s</b> 因触发设备规则已被禁用:%s\n请联系管理员解除禁用,或通过「个人资料 → 我的设备」踢下线多余设备后再申请恢复。",
|
||
html.EscapeString(u.Username), html.EscapeString(reason))
|
||
s.notify(ctx, userID, disableText)
|
||
s.notifyAdminMsg(ctx, disableText)
|
||
s.log.Warn("device policy: disabled account", zap.String("user", u.Username), zap.String("reason", reason))
|
||
}
|
||
|
||
func (s *DeviceService) UserRecentlyActive(ctx context.Context, userID string, within time.Duration) bool {
|
||
return s.sessions != nil && s.sessions.UserRecentlyActive(ctx, userID, within)
|
||
}
|
||
|
||
func (s *DeviceService) now() time.Time {
|
||
if s != nil && s.sessions != nil && s.sessions.now != nil {
|
||
return s.sessions.now()
|
||
}
|
||
return time.Now()
|
||
}
|
||
|
||
func (s *DeviceService) notify(ctx context.Context, userID, text string) {
|
||
if s.notifyUser != nil {
|
||
s.notifyUser(ctx, userID, text)
|
||
}
|
||
}
|
||
|
||
func (s *DeviceService) notifyAdminMsg(ctx context.Context, text string) {
|
||
if s.notifyAdmin != nil {
|
||
s.notifyAdmin(ctx, text)
|
||
}
|
||
}
|
||
|
||
func deviceLabel(name, client string) string {
|
||
name = strings.TrimSpace(name)
|
||
client = strings.TrimSpace(client)
|
||
switch {
|
||
case name != "" && client != "":
|
||
return name + " / " + client
|
||
case name != "":
|
||
return name
|
||
case client != "":
|
||
return client
|
||
default:
|
||
return "未知设备"
|
||
}
|
||
}
|