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) }