using System.Collections.Concurrent; using System.Net.Http.Headers; using System.Security.Cryptography; using System.Text; using System.Text.Json; using System.Text.RegularExpressions; namespace Wiki.Api; public sealed record WikiUser(string Id, string? Email, string? Name, long Exp); /// /// Проверка токена CRM (Supabase access_token) и доступа к workspace. /// Токены не логируются и не хранятся: в кэше ключ — SHA-256 токена. /// public sealed partial class SupabaseAuth(IHttpClientFactory http, IConfiguration cfg) { private readonly string _url = (cfg["Supabase:Url"] ?? "").TrimEnd('/'); private readonly string _key = cfg["Supabase:PublishableKey"] ?? ""; private readonly ConcurrentDictionary _users = new(); private readonly ConcurrentDictionary _ws = new(); private static readonly TimeSpan CacheFor = TimeSpan.FromMinutes(5); public const string Shared = "shared"; [GeneratedRegex("^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$")] private static partial Regex UuidRx(); public enum Fail { None, BadToken, Expired, Rejected, Unavailable } public async Task<(WikiUser? User, Fail Fail)> VerifyAsync(string token, CancellationToken ct) { var now = DateTimeOffset.UtcNow; var key = Hash(token); if (_users.TryGetValue(key, out var hit) && hit.Until > now && hit.User.Exp > now.ToUnixTimeSeconds()) return (hit.User, Fail.None); // 1. Разбор JWT: exp и aud. JsonElement claims; try { var parts = token.Split('.'); if (parts.Length != 3) return (null, Fail.BadToken); claims = JsonDocument.Parse(Base64Url(parts[1])).RootElement; } catch { return (null, Fail.BadToken); } if (!claims.TryGetProperty("exp", out var expEl) || !expEl.TryGetInt64(out var exp)) return (null, Fail.BadToken); if (exp <= now.ToUnixTimeSeconds()) return (null, Fail.Expired); if (!AudIsAuthenticated(claims)) return (null, Fail.Rejected); // 2. Supabase: GET /auth/v1/user. HttpResponseMessage r; try { using var req = new HttpRequestMessage(HttpMethod.Get, $"{_url}/auth/v1/user"); req.Headers.Add("apikey", _key); req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); r = await http.CreateClient("supabase").SendAsync(req, ct); } catch (Exception e) when (e is HttpRequestException or TaskCanceledException) { return (null, Fail.Unavailable); } using (r) { if (!r.IsSuccessStatusCode) return (null, (int)r.StatusCode >= 500 ? Fail.Unavailable : Fail.Rejected); using var doc = JsonDocument.Parse(await r.Content.ReadAsStringAsync(ct)); var u = doc.RootElement; if (Str(u, "aud") != "authenticated" || Str(u, "id") is not { } id) return (null, Fail.Rejected); string? name = null; if (u.TryGetProperty("user_metadata", out var md) && md.ValueKind == JsonValueKind.Object) name = Str(md, "name") ?? Str(md, "full_name") ?? Str(md, "display_name"); var user = new WikiUser(id, Str(u, "email"), string.IsNullOrWhiteSpace(name) ? null : name, exp); _users[key] = (user, now + CacheFor); if (_users.Count > 5000) Sweep(now); return (user, Fail.None); } } /// /// Нормализует и проверяет workspace. id из клиента не доверяем: /// shared — для всех, personal:<uid> — только владельцу, <uuid> (custom) — по таблице workspaces с RLS. /// public async Task CanAccessAsync(WikiUser user, string token, string workspaceId, CancellationToken ct) { if (workspaceId == Shared) return true; if (workspaceId.StartsWith("personal:", StringComparison.Ordinal)) return workspaceId == $"personal:{user.Id}"; if (!UuidRx().IsMatch(workspaceId)) return false; var now = DateTimeOffset.UtcNow; var key = user.Id + "|" + workspaceId.ToLowerInvariant(); if (_ws.TryGetValue(key, out var hit) && hit.Until > now) return hit.Ok; bool ok; try { using var req = new HttpRequestMessage(HttpMethod.Get, $"{_url}/rest/v1/workspaces?id=eq.{workspaceId.ToLowerInvariant()}&select=id"); req.Headers.Add("apikey", _key); req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); using var r = await http.CreateClient("supabase").SendAsync(req, ct); if (!r.IsSuccessStatusCode) return false; // ошибку не кэшируем using var doc = JsonDocument.Parse(await r.Content.ReadAsStringAsync(ct)); ok = doc.RootElement.ValueKind == JsonValueKind.Array && doc.RootElement.GetArrayLength() > 0; } catch (Exception e) when (e is HttpRequestException or TaskCanceledException or JsonException) { return false; } _ws[key] = (ok, now + CacheFor); return ok; } private void Sweep(DateTimeOffset now) { foreach (var (k, v) in _users) if (v.Until <= now) _users.TryRemove(k, out _); foreach (var (k, v) in _ws) if (v.Until <= now) _ws.TryRemove(k, out _); } private static bool AudIsAuthenticated(JsonElement claims) { if (!claims.TryGetProperty("aud", out var aud)) return false; if (aud.ValueKind == JsonValueKind.String) return aud.GetString() == "authenticated"; if (aud.ValueKind == JsonValueKind.Array) foreach (var a in aud.EnumerateArray()) if (a.ValueKind == JsonValueKind.String && a.GetString() == "authenticated") return true; return false; } private static string? Str(JsonElement e, string p) => e.TryGetProperty(p, out var v) && v.ValueKind == JsonValueKind.String ? v.GetString() : null; private static string Hash(string s) => Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(s))); private static byte[] Base64Url(string s) { s = s.Replace('-', '+').Replace('_', '/'); return Convert.FromBase64String(s.PadRight(s.Length + (4 - s.Length % 4) % 4, '=')); } }