- 新增媒体库:media_library/media_thumbs 服务(WebP 缩略图)、管理端 media 页面 - 移动端底部导航 MobileTabBar 替换 MobilePostBar - 旧数据导入增强与测试、路由与登录会话/板块侧边栏测试补强 - site-doc 组件精简(移除 Breadcrumb),文档新增迁移公告说明
220 lines
6.6 KiB
Go
220 lines
6.6 KiB
Go
package service
|
||
|
||
import (
|
||
"errors"
|
||
"time"
|
||
|
||
"github.com/freefire/jiang13-bbs/model"
|
||
"gorm.io/gorm"
|
||
)
|
||
|
||
const loginDeviceLimit = 20
|
||
|
||
var (
|
||
// ErrKickCurrentDevice 不能剔除自己正在用的会话
|
||
ErrKickCurrentDevice = errors.New("不能剔除当前正在使用的设备")
|
||
// ErrLoginDeviceNotFound 目标会话不存在、已过期或已吊销
|
||
ErrLoginDeviceNotFound = errors.New("该登录设备不存在或已失效")
|
||
)
|
||
|
||
// LoginDevice 用户安全设置里仍有效的登录会话(按设备指纹去重)
|
||
type LoginDevice struct {
|
||
ID uint `json:"id"`
|
||
IP string `json:"ip"`
|
||
DeviceKind string `json:"device_kind"`
|
||
DeviceLabel string `json:"device_label"`
|
||
Browser string `json:"browser"`
|
||
Current bool `json:"current"`
|
||
CreatedAt time.Time `json:"created_at"`
|
||
}
|
||
|
||
func sessionFamilyID(rt model.RefreshToken) uint {
|
||
if rt.FamilyID != 0 {
|
||
return rt.FamilyID
|
||
}
|
||
return rt.ID
|
||
}
|
||
|
||
func tokenLoggedInAt(rt model.RefreshToken) time.Time {
|
||
if !rt.LoggedInAt.IsZero() {
|
||
return rt.LoggedInAt
|
||
}
|
||
return rt.CreatedAt
|
||
}
|
||
|
||
func toLoginDevice(rt model.RefreshToken, current bool) LoginDevice {
|
||
ua := ParseUserAgent(rt.UserAgent)
|
||
ip := canonicalIP(rt.IP)
|
||
return LoginDevice{
|
||
ID: rt.ID,
|
||
IP: ip,
|
||
DeviceKind: ua.Kind,
|
||
DeviceLabel: ua.Label,
|
||
Browser: ua.Browser,
|
||
Current: current,
|
||
CreatedAt: tokenLoggedInAt(rt),
|
||
}
|
||
}
|
||
|
||
func pickCurrentSession(tokens []model.RefreshToken, currentFamily uint, reqFP string) *model.RefreshToken {
|
||
if currentFamily != 0 {
|
||
for i := range tokens {
|
||
if sessionFamilyID(tokens[i]) == currentFamily {
|
||
return &tokens[i]
|
||
}
|
||
}
|
||
}
|
||
for i := range tokens {
|
||
if deviceFingerprint(tokens[i].IP, tokens[i].UserAgent) == reqFP {
|
||
return &tokens[i]
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// ListLoginDevices 列出仍有效的登录会话,同一设备(归一化 IP + UA)只保留最近一条。
|
||
func (s *AuthService) ListLoginDevices(userID, currentFamily uint, clientIP, userAgent string) ([]LoginDevice, error) {
|
||
now := time.Now()
|
||
var tokens []model.RefreshToken
|
||
if err := s.db.Where("user_id = ? AND revoked = ? AND expires_at > ?", userID, false, now).
|
||
Order("last_used_at DESC, created_at DESC, id DESC").
|
||
Find(&tokens).Error; err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
reqFP := deviceFingerprint(clientIP, userAgent)
|
||
currentRT := pickCurrentSession(tokens, currentFamily, reqFP)
|
||
currentFP := ""
|
||
out := make([]LoginDevice, 0, 8)
|
||
if currentRT != nil {
|
||
currentFP = deviceFingerprint(currentRT.IP, currentRT.UserAgent)
|
||
out = append(out, toLoginDevice(*currentRT, true))
|
||
}
|
||
|
||
seen := make(map[string]struct{}, len(tokens))
|
||
if currentFP != "" {
|
||
seen[currentFP] = struct{}{}
|
||
}
|
||
for _, rt := range tokens {
|
||
fp := deviceFingerprint(rt.IP, rt.UserAgent)
|
||
if _, ok := seen[fp]; ok {
|
||
continue
|
||
}
|
||
seen[fp] = struct{}{}
|
||
out = append(out, toLoginDevice(rt, false))
|
||
if len(out) >= loginDeviceLimit {
|
||
break
|
||
}
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
// RevokeLoginDevice 剔除其它设备:吊销该设备指纹下除当前会话外的全部有效 refresh token。
|
||
func (s *AuthService) RevokeLoginDevice(userID, currentFamily, deviceID uint) error {
|
||
now := time.Now()
|
||
var target model.RefreshToken
|
||
if err := s.db.Where("id = ? AND user_id = ? AND revoked = ? AND expires_at > ?",
|
||
deviceID, userID, false, now).First(&target).Error; err != nil {
|
||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||
return ErrLoginDeviceNotFound
|
||
}
|
||
return err
|
||
}
|
||
if currentFamily != 0 && sessionFamilyID(target) == currentFamily {
|
||
return ErrKickCurrentDevice
|
||
}
|
||
|
||
fp := deviceFingerprint(target.IP, target.UserAgent)
|
||
var actives []model.RefreshToken
|
||
if err := s.db.Select("id", "family_id", "ip", "user_agent").
|
||
Where("user_id = ? AND revoked = ? AND expires_at > ?", userID, false, now).
|
||
Find(&actives).Error; err != nil {
|
||
return err
|
||
}
|
||
|
||
ids := make([]uint, 0, len(actives))
|
||
for _, t := range actives {
|
||
if currentFamily != 0 && sessionFamilyID(t) == currentFamily {
|
||
continue
|
||
}
|
||
if deviceFingerprint(t.IP, t.UserAgent) == fp {
|
||
ids = append(ids, t.ID)
|
||
}
|
||
}
|
||
if len(ids) == 0 {
|
||
return ErrKickCurrentDevice
|
||
}
|
||
return s.db.Model(&model.RefreshToken{}).Where("id IN ?", ids).
|
||
Updates(map[string]any{"revoked": true, "token_cipher": "", "updated_at": now}).Error
|
||
}
|
||
|
||
func (s *AuthService) LookupFamilyByRefreshPlain(plain string, userID uint) uint {
|
||
if plain == "" {
|
||
return 0
|
||
}
|
||
var rt model.RefreshToken
|
||
if err := s.db.Select("id", "user_id", "family_id", "revoked", "expires_at").
|
||
Where("token_hash = ?", hashRefreshToken(plain)).First(&rt).Error; err != nil {
|
||
return 0
|
||
}
|
||
if rt.UserID != userID || rt.Revoked || time.Now().After(rt.ExpiresAt) {
|
||
return 0
|
||
}
|
||
return sessionFamilyID(rt)
|
||
}
|
||
|
||
// TouchDeviceFromRefresh 刷新本会话活跃时间,并回填尚未写入的 family_id。
|
||
// 会话的 IP/UA 以登录时记录为准,不随访问端环境漂移,
|
||
// 否则设备指纹会跟着请求变,设备列表在 UA 模拟/代理下会来回跳。
|
||
func (s *AuthService) TouchDeviceFromRefresh(plain string, userID uint) uint {
|
||
if plain == "" {
|
||
return 0
|
||
}
|
||
var rt model.RefreshToken
|
||
if err := s.db.Where("token_hash = ?", hashRefreshToken(plain)).First(&rt).Error; err != nil {
|
||
return 0
|
||
}
|
||
if rt.UserID != userID || rt.Revoked || time.Now().After(rt.ExpiresAt) {
|
||
return 0
|
||
}
|
||
now := time.Now()
|
||
upd := map[string]any{"last_used_at": now, "updated_at": now}
|
||
fid := sessionFamilyID(rt)
|
||
if rt.FamilyID == 0 {
|
||
upd["family_id"] = fid
|
||
}
|
||
_ = s.db.Model(&rt).Updates(upd).Error
|
||
return fid
|
||
}
|
||
|
||
// maxActiveSessions 每个用户允许并存的活跃会话(登录设备)上限,
|
||
// 与设备列表展示上限 loginDeviceLimit 一致;超出时淘汰最久未使用的会话。
|
||
const maxActiveSessions = 20
|
||
|
||
// enforceSessionCap 会话上限守护:多会话并存下新登录不踢旧设备,仅当该用户
|
||
// 活跃会话超过 maxActiveSessions 时,按 last_used_at 淘汰最旧的会话腾位;
|
||
// 返回淘汰行数供上层广播告知(新会话 last_used_at 最新,不会被淘汰)。
|
||
func (s *AuthService) enforceSessionCap(userID uint) int {
|
||
now := time.Now()
|
||
var actives []model.RefreshToken
|
||
if err := s.db.Select("id").
|
||
Where("user_id = ? AND revoked = ? AND expires_at > ?", userID, false, now).
|
||
Order("last_used_at DESC, created_at DESC, id DESC").
|
||
Find(&actives).Error; err != nil {
|
||
return 0
|
||
}
|
||
if len(actives) <= maxActiveSessions {
|
||
return 0
|
||
}
|
||
ids := make([]uint, 0, len(actives)-maxActiveSessions)
|
||
for _, t := range actives[maxActiveSessions:] {
|
||
ids = append(ids, t.ID)
|
||
}
|
||
res := s.db.Model(&model.RefreshToken{}).Where("id IN ?", ids).
|
||
Updates(map[string]any{"revoked": true, "token_cipher": "", "updated_at": now})
|
||
if res.Error != nil {
|
||
return 0
|
||
}
|
||
return int(res.RowsAffected)
|
||
}
|