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 用当前请求的 IP/UA 校准本会话,并回填尚未写入的 family_id。 func (s *AuthService) TouchDeviceFromRefresh(plain, ip, ua 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} if v := truncateStr(ip, 45); v != "" { upd["ip"] = v } if v := truncateStr(ua, 500); v != "" { upd["user_agent"] = v } fid := sessionFamilyID(rt) if rt.FamilyID == 0 { upd["family_id"] = fid } _ = s.db.Model(&rt).Updates(upd).Error return fid } // revokeAllSessionsExcept 真·单会话:登录时吊销该用户除新会话外的全部 // 有效会话(任何新登录都踢掉所有旧设备);返回吊销行数供上层广播告知。 func (s *AuthService) revokeAllSessionsExcept(userID, keepFamily uint) int { now := time.Now() var actives []model.RefreshToken if err := s.db.Select("id", "family_id"). Where("user_id = ? AND revoked = ? AND expires_at > ?", userID, false, now). Find(&actives).Error; err != nil { return 0 } ids := make([]uint, 0) for _, t := range actives { if sessionFamilyID(t) == keepFamily { continue } ids = append(ids, t.ID) } if len(ids) == 0 { return 0 } 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) }