using System.Globalization; using System.Net; using System.Net.Http.Headers; using System.Security.Cryptography; using System.Text; using System.Text.Json; using System.Text.RegularExpressions; namespace Server.WatchBody.Fetch; /// Неуспех SSO-логина (шаги 1–5; MFA/блокировка). Текст без /// пароля (🛡4). public class GarminAuthException(string message) : Exception(message); /// Одна активность из списка Garmin Connect (spec 01 §1). public sealed record GarminActivity(long ActivityId, DateTime StartTimeLocal, string ActivityType); /// /// Неофициальный клиент Garmin Connect — по контракту семьи библиотек /// `garminconnect` (tech-req разд. 5): SSO-логин → OAuth1 → Bearer; /// список активностей и скачивание .fit. HttpMessageHandler инъецируется /// (тесты — FakeGarminHandler). 🛡 пароль/токены не логируются. /// public class GarminClient { // consumer-пара семьи garminconnect (прототип; при блокировке — // решение ОВ этапа 7, fallback — папка импорта) public const string ConsumerKey = "C2JDomY7p9eCA2R5OsBTb3tS6aIn9k2"; public const string ConsumerSecret = "O45MeVZGRDTv6qnKZaZjfMndAQ7Qu9FTH7IHZP1YmBySpTOd"; private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(30); private readonly string _login, _password, _base; private readonly HttpClient _http; private readonly CookieContainer _cookies; private string? _bearer; public GarminClient(string login, string password, string baseUrl, HttpMessageHandler? handler = null) { _login = login; _password = password; _base = baseUrl.TrimEnd('/'); _cookies = new CookieContainer(); _http = new HttpClient(handler ?? new HttpClientHandler { CookieContainer = _cookies, AllowAutoRedirect = false, }) { Timeout = Timeout }; if (handler == null) return; // фейковый handler ведёт куки сам — отдадим ему контейнер через свойства if (handler is FakeGarminHandler fake) fake.Cookies = _cookies; } /// SSO-последовательность (spec 01 §2, шаги 1–5). public void Login(CancellationToken ct = default) { Get($"{_base}/sso/embed", ct); // 1 var signinHtml = Get($"{_base}/sso/signin", ct); // 2 var csrf = Regex.Match(signinHtml, "name=\"_csrf\"\\s+value=\"([^\"]+)\"").Groups[1].Value ?? throw new GarminAuthException("signin: csrf не найден"); var (status, body) = PostForm($"{_base}/sso/signin", new Dictionary { ["username"] = _login, ["password"] = _password, ["embed"] = "true", ["_csrf"] = csrf, }, ct); // 3 var ticket = Regex.Match(body, "[?&]ticket=([^&\"']+)", RegexOptions.IgnoreCase).Groups[1].Value; if (status != HttpStatusCode.OK && status != HttpStatusCode.Found || ticket.Length == 0) throw new GarminAuthException($"signin не удался (HTTP {(int)status}; возможны MFA/блокировка)"); Get($"{_base}/modern/?ticket={Uri.EscapeDataString(ticket)}", ct); // 4 // 5: OAuth1-обмен → Bearer var oauth = OAuth1Header("POST", $"{_base}/sso/api/2.0/oauth/exchange/service/user/2.0"); var (exStatus, exBody) = PostAuthorized( $"{_base}/sso/api/2.0/oauth/exchange/service/user/2.0", oauth, ct); if (exStatus != HttpStatusCode.OK) throw new GarminAuthException($"oauth exchange не удался (HTTP {(int)exStatus})"); using var json = JsonDocument.Parse(exBody); _bearer = json.RootElement.TryGetProperty("access_token", out var token) ? token.GetString() : throw new GarminAuthException("oauth exchange: access_token отсутствует"); } /// Список активностей (шаг 6). public List ListActivities(int limit, CancellationToken ct = default) { var list = Get($"{_base}/activitylist-service/activities/search/activities" + $"?limit={limit}&start=0", ct, authorized: true); using var json = JsonDocument.Parse(list); return json.RootElement.EnumerateArray() .Where(a => a.TryGetProperty("activityId", out _)) .Select(a => new GarminActivity( a.GetProperty("activityId").GetInt64(), a.TryGetProperty("startTimeLocal", out var t) && DateTime.TryParse(t.GetString(), CultureInfo.InvariantCulture, DateTimeStyles.None, out var parsed) ? parsed : default, a.TryGetProperty("activityType", out var type) && type.TryGetProperty("typeKey", out var key) ? key.GetString() ?? "" : "")) .ToList(); } /// Байты .fit активности (шаг 7). public byte[] DownloadFit(long activityId, CancellationToken ct = default) => _http.GetByteArrayAsync( $"{_base}/download-service/files/activity/{activityId}", ct) .GetAwaiter().GetResult(); // ---------- низкоуровневые ---------- private string Get(string url, CancellationToken ct, bool authorized = false) { using var request = new HttpRequestMessage(HttpMethod.Get, url); if (authorized) request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _bearer); var response = Send(request, ct); return response.Content.ReadAsStringAsync(ct).GetAwaiter().GetResult(); } private (HttpStatusCode Status, string Body) PostForm( string url, Dictionary fields, CancellationToken ct) { using var request = new HttpRequestMessage(HttpMethod.Post, url) { Content = new FormUrlEncodedContent(fields), }; var response = Send(request, ct); return (response.StatusCode, response.Content.ReadAsStringAsync(ct).GetAwaiter().GetResult()); } private (HttpStatusCode Status, string Body) PostAuthorized( string url, string oauthHeader, CancellationToken ct) { using var request = new HttpRequestMessage(HttpMethod.Post, url); request.Headers.TryAddWithoutValidation("Authorization", oauthHeader); var response = Send(request, ct); return (response.StatusCode, response.Content.ReadAsStringAsync(ct).GetAwaiter().GetResult()); } private HttpResponseMessage Send(HttpRequestMessage request, CancellationToken ct) { var response = _http.SendAsync(request, ct).GetAwaiter().GetResult(); foreach (var cookie in response.Headers .Where(h => h.Key.Equals("Set-Cookie", StringComparison.OrdinalIgnoreCase)) .SelectMany(h => h.Value)) SaveCookie(request.RequestUri!, cookie); return response; } private void SaveCookie(Uri uri, string setCookie) { var parts = setCookie.Split(';', 2)[0].Split('=', 2); if (parts.Length == 2) _cookies.Add(uri, new Cookie(parts[0].Trim(), parts[1].Trim())); } /// OAuth1 Authorization-заголовок (HMAC-SHA1) для обменного /// шага (по контракту garminconnect). private string OAuth1Header(string method, string url) { var nonce = Guid.NewGuid().ToString("N"); var timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds() .ToString(CultureInfo.InvariantCulture); var parameters = new SortedDictionary { ["oauth_consumer_key"] = ConsumerKey, ["oauth_nonce"] = nonce, ["oauth_signature_method"] = "HMAC-SHA1", ["oauth_timestamp"] = timestamp, ["oauth_version"] = "1.0", }; var baseString = $"{method}&{Uri.EscapeDataString(url)}&" + Uri.EscapeDataString(string.Join("&", parameters.Select(p => $"{p.Key}={Uri.EscapeDataString(p.Value)}"))); var key = $"{Uri.EscapeDataString(ConsumerSecret)}&"; string signature; using (var hmac = new HMACSHA1(Encoding.ASCII.GetBytes(key))) signature = Convert.ToBase64String(hmac.ComputeHash(Encoding.ASCII.GetBytes(baseString))); return "OAuth " + string.Join(", ", parameters.Select(p => $"{p.Key}=\"{Uri.EscapeDataString(p.Value)}\"")) + $", oauth_signature=\"{Uri.EscapeDataString(signature)}\""; } } /// Фейковый Garmin для тестов (spec 01 §3): маршруты spec 01 §2 /// на подменяемом base; список/байты задаёт тест. public class FakeGarminHandler : HttpMessageHandler { public CookieContainer? Cookies { get; set; } public bool FailLogin { get; set; } public List Activities { get; set; } = []; public Dictionary FitFiles { get; set; } = []; public int LoginCalls, ListCalls, DownloadCalls; protected override Task SendAsync( HttpRequestMessage request, CancellationToken cancellationToken) { var url = request.RequestUri!.ToString(); var response = new HttpResponseMessage(HttpStatusCode.OK); if (url.EndsWith("/sso/embed")) { /* шаг 1 — cookies выставит Send-обвязка */ } else if (url.EndsWith("/sso/signin") && request.Method == HttpMethod.Get) response.Content = new StringContent( ""); else if (url.EndsWith("/sso/signin")) { LoginCalls++; if (FailLogin) { response.StatusCode = HttpStatusCode.Unauthorized; response.Content = new StringContent("auth failed"); } else response.Content = new StringContent( "…?ticket=ST-123456-fake&…"); } else if (url.Contains("/modern/?ticket=")) { /* шаг 4 */ } else if (url.Contains("/oauth/exchange")) response.Content = new StringContent( """{"access_token":"fake-bearer-token","expires_in":3599}"""); else if (url.Contains("/activitylist-service")) { ListCalls++; response.Content = new StringContent( JsonSerializer.Serialize(Activities.Select(a => new Dictionary { ["activityId"] = a.ActivityId, ["startTimeLocal"] = a.StartTimeLocal.ToString( "yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture), ["activityType"] = new Dictionary { ["typeKey"] = a.ActivityType }, }))); } else if (url.Contains("/download-service/files/activity/")) { DownloadCalls++; var id = long.Parse(url.Split('/').Last()); response.Content = new ByteArrayContent(FitFiles[id]); } else response.StatusCode = HttpStatusCode.NotFound; return Task.FromResult(response); } }