Files
crm-calls/calls/backend/Calls.Api/Auth.cs

182 lines
9.6 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 Calls.Api;
public sealed record CrmUser(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, (CrmUser User, DateTimeOffset Until)> _users = new();
private readonly ConcurrentDictionary<string, (bool Ok, DateTimeOffset Until)> _ws = new();
private static readonly TimeSpan CacheFor = TimeSpan.FromMinutes(5);
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<(CrmUser? 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 CrmUser(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. id из клиента не доверяем:
/// shared — для всех, personal:&lt;uid&gt; — только владельцу, остальные (custom, напр. wsmv5l7d425re160) —
/// запросом к таблице workspaces Supabase с токеном пользователя: RLS отдаёт строку, только если он владелец или участник.
/// </summary>
public async Task<bool> CanAccessAsync(CrmUser user, string token, string workspaceId, CancellationToken ct)
{
if (workspaceId == Shared) return true;
if (workspaceId.StartsWith("personal:", StringComparison.Ordinal))
return workspaceId == $"personal:{user.Id}";
if (!CustomWsRx().IsMatch(workspaceId)) return false;
var now = DateTimeOffset.UtcNow;
var key = user.Id + "|" + workspaceId;
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.{Uri.EscapeDataString(workspaceId)}&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;
}
public sealed record WorkspaceInfo(string Id, string Kind, string Title);
/// <summary>
/// Workspace пользователя для выбора, когда CRM не прислала workspace в crm:auth.
/// Как в CRM (model.js workspaces()): «Мій» (personal:&lt;uid&gt;), «Загальний» (shared) и custom-строки
/// таблицы workspaces, где он владелец или участник (RLS отдаёт только их, фильтр — подстраховка).
/// null — Supabase недоступен.
/// </summary>
public async Task<List<WorkspaceInfo>?> ListWorkspacesAsync(CrmUser user, string token, CancellationToken ct)
{
var list = new List<WorkspaceInfo>
{
new($"personal:{user.Id}", "personal", "Мій"),
new(Shared, "shared", "Загальний"),
};
try
{
using var req = new HttpRequestMessage(HttpMethod.Get,
$"{_url}/rest/v1/workspaces?select=id,kind,title,owner_uid,members&kind=eq.custom");
req.Headers.Add("apikey", _key);
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
using var r = await http.CreateClient("supabase").SendAsync(req, ct);
// Таблицы может не быть (схему CRM не обновили) — тогда только «Мій» и «Загальний».
if ((int)r.StatusCode == 404) return list;
if (!r.IsSuccessStatusCode) return null;
using var doc = JsonDocument.Parse(await r.Content.ReadAsStringAsync(ct));
if (doc.RootElement.ValueKind != JsonValueKind.Array) return null;
var custom = new List<WorkspaceInfo>();
foreach (var w in doc.RootElement.EnumerateArray())
{
if (Str(w, "id") is not { } id || !CustomWsRx().IsMatch(id)) continue;
var mine = Str(w, "owner_uid") == user.Id;
if (!mine && w.TryGetProperty("members", out var m) && m.ValueKind == JsonValueKind.Array)
mine = m.EnumerateArray().Any(x => x.ValueKind == JsonValueKind.String && x.GetString() == user.Id);
if (!mine) continue;
var title = Str(w, "title");
custom.Add(new(id, "custom", string.IsNullOrWhiteSpace(title) ? "Без назви" : title.Trim()));
_ws[user.Id + "|" + id] = (true, DateTimeOffset.UtcNow + CacheFor);
}
list.AddRange(custom.OrderBy(w => w.Title, StringComparer.Create(new System.Globalization.CultureInfo("uk-UA"), true)));
return list;
}
catch (Exception e) when (e is HttpRequestException or TaskCanceledException or JsonException) { return 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, '='));
}
}