Files
crm-wiki/backend/Wiki.Api/Auth.cs

193 lines
11 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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:&lt;uid&gt; — владельцу, а другим — по выдаче доступа 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/&lt;query&gt; с токеном пользователя. 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, '='));
}
}