193 lines
11 KiB
C#
193 lines
11 KiB
C#
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);
|
||
|
||
/// <summary>
|
||
/// Проверка токена CRM (Supabase access_token) и доступа к workspace.
|
||
/// Токены не логируются и не хранятся: в кэше ключ — SHA-256 токена.
|
||
/// </summary>
|
||
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<string, (WikiUser User, DateTimeOffset Until)> _users = new();
|
||
private readonly ConcurrentDictionary<string, (Access Access, DateTimeOffset Until)> _ws = new();
|
||
private static readonly TimeSpan CacheFor = TimeSpan.FromMinutes(5);
|
||
// Доступ к workspace кэшируем коротко: выданный или отозванный в CRM доступ вступает в силу в пределах минуты.
|
||
private static readonly TimeSpan WsCacheFor = TimeSpan.FromMinutes(1);
|
||
|
||
public const string Shared = "shared";
|
||
|
||
// id пользовательского workspace CRM — текст: 'ws' + base36 (createWorkspace в CRM, напр. wsmv5l7d425re160);
|
||
// принимаем и любые похожие текстовые id (в т. ч. uuid). Только безопасные символы — значение идёт в фильтр PostgREST.
|
||
[GeneratedRegex("^[A-Za-z0-9_-]{1,64}$")]
|
||
private static partial Regex CustomWsRx();
|
||
|
||
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);
|
||
}
|
||
}
|
||
|
||
/// <summary>Права в workspace: нет, только чтение, полные.</summary>
|
||
public enum Access { None, Read, Full }
|
||
|
||
public async Task<bool> CanAccessAsync(WikiUser user, string token, string workspaceId, CancellationToken ct) =>
|
||
await AccessAsync(user, token, workspaceId, ct) != Access.None;
|
||
|
||
/// <summary>
|
||
/// Нормализует и проверяет workspace. id из клиента не доверяем:
|
||
/// shared — для всех; personal:<uid> — владельцу, а другим — по выдаче доступа CRM (access_grants);
|
||
/// остальные (custom, напр. wsmv5l7d425re160) — запросом к таблице workspaces Supabase с токеном пользователя
|
||
/// (RLS отдаёт строку владельцу, участнику и тому, кому открыли спільні воркспейси через access_grants.shared).
|
||
/// </summary>
|
||
public async Task<Access> AccessAsync(WikiUser user, string token, string workspaceId, CancellationToken ct)
|
||
{
|
||
if (workspaceId == Shared) return Access.Full;
|
||
string? owner = null;
|
||
if (workspaceId.StartsWith("personal:", StringComparison.Ordinal))
|
||
{
|
||
owner = workspaceId["personal:".Length..];
|
||
if (owner == user.Id) return Access.Full;
|
||
if (!Guid.TryParse(owner, out _)) return Access.None; // uid Supabase — uuid; значение идёт в фильтр PostgREST
|
||
}
|
||
else if (!CustomWsRx().IsMatch(workspaceId)) return Access.None;
|
||
|
||
var now = DateTimeOffset.UtcNow;
|
||
var key = user.Id + "|" + workspaceId;
|
||
if (_ws.TryGetValue(key, out var hit) && hit.Until > now) return hit.Access;
|
||
Access? got;
|
||
try { got = owner is not null ? await PersonalGrantAsync(user, token, owner, ct) : await CustomAsync(user, token, workspaceId, ct); }
|
||
catch (Exception e) when (e is HttpRequestException or TaskCanceledException or JsonException) { return Access.None; }
|
||
if (got is not { } access) return Access.None; // ошибку не кэшируем
|
||
_ws[key] = (access, now + WsCacheFor);
|
||
return access;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Чужое личное: строка access_grants владельца для меня (RLS отдаёт адресату его строки). Личная вики — часть
|
||
/// личного воркспейса, поэтому её открывает «Мої особисті проєкти: Усі» (projects = all), права — level (read/full).
|
||
/// «Вибрані» проєкты и один лишь план вики не открывают.
|
||
/// </summary>
|
||
private async Task<Access?> PersonalGrantAsync(WikiUser user, string token, string owner, CancellationToken ct)
|
||
{
|
||
var rows = await SelectAsync(token,
|
||
$"access_grants?owner_uid=eq.{owner}&grantee_uid=eq.{Uri.EscapeDataString(user.Id)}&select=projects,level", ct);
|
||
if (rows is null) return null;
|
||
foreach (var g in rows.Value.EnumerateArray())
|
||
if (Str(g, "projects") == "all") return Str(g, "level") == "full" ? Access.Full : Access.Read;
|
||
return Access.None;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Свой custom (владелец или участник) — полные права. Строка видна, но я не владелец и не участник — значит,
|
||
/// её открыли через access_grants.shared = all владельцем или участником этого воркспейса: права по level, как в CRM.
|
||
/// </summary>
|
||
private async Task<Access?> CustomAsync(WikiUser user, string token, string workspaceId, CancellationToken ct)
|
||
{
|
||
var ws = await SelectAsync(token, $"workspaces?id=eq.{Uri.EscapeDataString(workspaceId)}&select=id,owner_uid,members", ct);
|
||
if (ws is null) return null;
|
||
if (ws.Value.GetArrayLength() == 0) return Access.None;
|
||
var row = ws.Value[0];
|
||
var people = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||
if (Str(row, "owner_uid") is { } o) people.Add(o);
|
||
if (row.TryGetProperty("members", out var m) && m.ValueKind == JsonValueKind.Array)
|
||
foreach (var x in m.EnumerateArray()) if (x.ValueKind == JsonValueKind.String) people.Add(x.GetString()!);
|
||
if (people.Contains(user.Id)) return Access.Full;
|
||
|
||
var grants = await SelectAsync(token,
|
||
$"access_grants?grantee_uid=eq.{Uri.EscapeDataString(user.Id)}&shared=eq.all&select=owner_uid,level", ct);
|
||
if (grants is null) return null;
|
||
var via = grants.Value.EnumerateArray().Where(g => Str(g, "owner_uid") is { } ow && people.Contains(ow)).ToList();
|
||
if (via.Count == 0) return Access.None;
|
||
return via.Any(g => Str(g, "level") == "full") ? Access.Full : Access.Read;
|
||
}
|
||
|
||
/// <summary>GET /rest/v1/<query> с токеном пользователя. null — ошибка (не 2xx или не массив).</summary>
|
||
private async Task<JsonElement?> SelectAsync(string token, string query, CancellationToken ct)
|
||
{
|
||
using var req = new HttpRequestMessage(HttpMethod.Get, $"{_url}/rest/v1/{query}");
|
||
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 null;
|
||
using var doc = JsonDocument.Parse(await r.Content.ReadAsStringAsync(ct));
|
||
return doc.RootElement.ValueKind == JsonValueKind.Array ? doc.RootElement.Clone() : null;
|
||
}
|
||
|
||
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, '='));
|
||
}
|
||
}
|