172 lines
9.1 KiB
C#
172 lines
9.1 KiB
C#
using System.Collections.Concurrent;
|
|
using System.Net;
|
|
using System.Security.Cryptography;
|
|
using Microsoft.AspNetCore.Http;
|
|
|
|
namespace WxAgent.Service;
|
|
|
|
public sealed class ServiceSecurity(ServiceOptions options)
|
|
{
|
|
private static readonly ServiceIdentity LocalIdentity = new("local", "local", ["read", "content", "manage", "local-admin"], [], true);
|
|
private sealed record BrowserSession(ServiceIdentity Identity, string Csrf, DateTimeOffset Expires);
|
|
private readonly ConcurrentDictionary<string, BrowserSession> sessions = new();
|
|
private readonly object loginGate = new();
|
|
private DateTimeOffset loginWindow = DateTimeOffset.UtcNow;
|
|
private int loginAttempts;
|
|
public const string CookieName = "wxagent-session";
|
|
|
|
public ServiceIdentity? AuthenticateToken(string token)
|
|
{
|
|
if (!ServiceOptions.IsValidAccessToken(token)) return null;
|
|
var hash = ServiceOptions.HashToken(token);
|
|
return ReadCredentialsSafely().Where(c => EqualHash(c.TokenSha256, hash))
|
|
.Select(c => new ServiceIdentity(c.PrincipalId, c.TokenSha256, c.Permissions, c.AccountIds)).SingleOrDefault();
|
|
}
|
|
|
|
public ServiceIdentity RequireCurrent(ServiceIdentity original, string? permission = null, string? accountId = null)
|
|
{
|
|
if (original.LocalOnly)
|
|
{
|
|
if (permission is not null && !original.Allows(permission))
|
|
throw new ServiceException("Forbidden", 403, "Permission required.");
|
|
return original;
|
|
}
|
|
var credential = ReadCredentialsSafely().SingleOrDefault(c => c.PrincipalId == original.PrincipalId &&
|
|
EqualHash(c.TokenSha256, original.CredentialHash));
|
|
if (credential is null) throw new ServiceException("AuthorizationRevoked", 401, "Credential expired or revoked.");
|
|
var current = new ServiceIdentity(credential.PrincipalId, credential.TokenSha256, credential.Permissions, credential.AccountIds);
|
|
if (permission is not null && !current.Allows(permission))
|
|
throw new ServiceException("Forbidden", 403, "Permission required.");
|
|
// AccountIds is retained for credential-file compatibility. Account-level business isolation belongs to callers;
|
|
// the service still validates the selected account/window/target at the backend boundary.
|
|
return current;
|
|
}
|
|
|
|
private ServiceCredential[] ReadCredentialsSafely()
|
|
{
|
|
try { return options.ReadCredentials(); }
|
|
catch (Exception e) when (e is IOException or InvalidDataException or UnauthorizedAccessException or System.Text.Json.JsonException or ArgumentException)
|
|
{ return []; } // Fail closed during invalid or incomplete local rotation.
|
|
}
|
|
|
|
private static bool EqualHash(string a, string b) => CryptographicOperations.FixedTimeEquals(
|
|
Convert.FromHexString(a), Convert.FromHexString(b));
|
|
|
|
public void ValidateSource(HttpContext context)
|
|
{
|
|
if (!IsAllowedHost(context.Request.Host.Value))
|
|
throw new ServiceException("UntrustedHost", 403, "Request host is not trusted.");
|
|
var origin = context.Request.Headers.Origin.ToString();
|
|
if (!string.IsNullOrWhiteSpace(origin) && !IsAllowedOrigin(origin))
|
|
throw new ServiceException("UntrustedOrigin", 403, "Request origin is not trusted.");
|
|
}
|
|
|
|
public static void RequireLocal(HttpContext context)
|
|
{
|
|
if (context.Connection.RemoteIpAddress is not { } address || !IPAddress.IsLoopback(address))
|
|
throw new ServiceException("LocalOnly", 403, "This action requires a direct loopback connection.");
|
|
}
|
|
|
|
public ServiceIdentity AuthenticateRequest(HttpContext context)
|
|
{
|
|
if (context.Request.Headers.Authorization.Count != 0)
|
|
{
|
|
var header = context.Request.Headers.Authorization.ToString();
|
|
if (header.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase) && AuthenticateToken(header[7..]) is { } identity)
|
|
return identity;
|
|
throw new ServiceException("Unauthorized", 401, "Valid Bearer credential required.");
|
|
}
|
|
var isMutation = !HttpMethods.IsGet(context.Request.Method) && !HttpMethods.IsHead(context.Request.Method);
|
|
if (!context.Request.Cookies.TryGetValue(CookieName, out var id) || !sessions.TryGetValue(id, out var session))
|
|
{
|
|
if (!IsLoopback(context)) throw new ServiceException("Unauthorized", 401, "Login required.");
|
|
if (isMutation)
|
|
{
|
|
RequireTrustedOrigin(context, required: true);
|
|
if (context.Request.Headers["X-WxAgent-Local"].ToString() != "1")
|
|
throw new ServiceException("LocalRequestHeaderRequired", 403, "Browser writes require the local request header.");
|
|
}
|
|
return LocalIdentity;
|
|
}
|
|
if (session.Identity.LocalOnly && !IsLoopback(context))
|
|
throw new ServiceException("Unauthorized", 401, "Local session cannot be used remotely.");
|
|
if (session.Expires <= DateTimeOffset.UtcNow)
|
|
{
|
|
sessions.TryRemove(id, out _);
|
|
throw new ServiceException("Unauthorized", 401, "Session expired.");
|
|
}
|
|
var current = RequireCurrent(session.Identity);
|
|
if (isMutation)
|
|
{
|
|
RequireTrustedOrigin(context, required: true);
|
|
if (context.Request.Headers["X-CSRF-Token"].ToString() != session.Csrf)
|
|
throw new ServiceException("CsrfRejected", 403, "CSRF token required.");
|
|
}
|
|
return current;
|
|
}
|
|
|
|
public object Login(HttpContext context, string token)
|
|
{
|
|
RequireTrustedOrigin(context, required: true);
|
|
// ponytail: process-wide login throttle; per-IP quotas only if legitimate shared use needs them.
|
|
lock (loginGate)
|
|
{
|
|
if (DateTimeOffset.UtcNow - loginWindow > TimeSpan.FromMinutes(1))
|
|
{ loginWindow = DateTimeOffset.UtcNow; loginAttempts = 0; }
|
|
if (++loginAttempts > 10) throw new ServiceException("RateLimited", 429, "Wait before attempting login again.");
|
|
}
|
|
var identity = AuthenticateToken(token) ?? throw new ServiceException("Unauthorized", 401, "Invalid credential.");
|
|
foreach (var entry in sessions.Where(s => s.Value.Expires <= DateTimeOffset.UtcNow)) sessions.TryRemove(entry.Key, out _);
|
|
if (sessions.Count >= 100) throw new ServiceException("RateLimited", 429, "Browser session limit reached.");
|
|
var id = Convert.ToHexString(RandomNumberGenerator.GetBytes(32));
|
|
var csrf = Convert.ToHexString(RandomNumberGenerator.GetBytes(32));
|
|
var expires = DateTimeOffset.UtcNow.AddMinutes(30);
|
|
sessions[id] = new BrowserSession(identity, csrf, expires);
|
|
context.Response.Cookies.Append(CookieName, id, new CookieOptions
|
|
{ HttpOnly = true, SameSite = SameSiteMode.Strict, Secure = false, Path = "/", MaxAge = TimeSpan.FromMinutes(30), IsEssential = true });
|
|
return new { identity.PrincipalId, identity.Permissions, identity.AccountIds, csrfToken = csrf, expires };
|
|
}
|
|
|
|
private void RequireTrustedOrigin(HttpContext context, bool required)
|
|
{
|
|
var origin = context.Request.Headers.Origin.ToString();
|
|
if (string.IsNullOrWhiteSpace(origin))
|
|
{
|
|
if (required) throw new ServiceException("OriginRequired", 403, "A trusted Origin is required for browser writes.");
|
|
return;
|
|
}
|
|
if (!IsAllowedOrigin(origin)) throw new ServiceException("UntrustedOrigin", 403, "Request origin is not trusted.");
|
|
}
|
|
|
|
private bool IsAllowedHost(string host)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(host)) return false;
|
|
if (options.AllowedHosts.Any(value => string.Equals(value, host, StringComparison.OrdinalIgnoreCase))) return true;
|
|
if (!Uri.TryCreate(options.ListenUrl, UriKind.Absolute, out var listen) ||
|
|
!IPAddress.TryParse(listen.Host.Trim('[', ']'), out var address) || IsAnyAddress(address)) return false;
|
|
return string.Equals(listen.Authority, host, StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
private bool IsAllowedOrigin(string origin)
|
|
{
|
|
if (!Uri.TryCreate(origin, UriKind.Absolute, out var parsed) || parsed.Scheme != "http" ||
|
|
parsed.UserInfo.Length != 0 || parsed.AbsolutePath != "/" || parsed.Query.Length != 0 || parsed.Fragment.Length != 0)
|
|
return false;
|
|
var normalized = origin.TrimEnd('/');
|
|
if (options.AllowedOrigins.Any(value => string.Equals(value.TrimEnd('/'), normalized, StringComparison.OrdinalIgnoreCase))) return true;
|
|
return Uri.TryCreate(options.ListenUrl, UriKind.Absolute, out var listen) &&
|
|
string.Equals(listen.GetLeftPart(UriPartial.Authority), normalized, StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
private static bool IsAnyAddress(IPAddress address) => address.Equals(IPAddress.Any) || address.Equals(IPAddress.IPv6Any);
|
|
|
|
private static bool IsLoopback(HttpContext context) =>
|
|
context.Connection.RemoteIpAddress is { } address && IPAddress.IsLoopback(address);
|
|
|
|
public void Logout(HttpContext context)
|
|
{
|
|
if (context.Request.Cookies.TryGetValue(CookieName, out var id)) sessions.TryRemove(id, out _);
|
|
context.Response.Cookies.Delete(CookieName, new CookieOptions { Path = "/" });
|
|
}
|
|
}
|