Files
wx-win-agent/src/WxAgent.Service/ServiceSecurity.cs
T
2026-09-07 19:07:16 +08:00

116 lines
6.4 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 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 (token.Length is < 43 or > 256) 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)
{
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.");
if (accountId is not null && !current.AllowsAccount(accountId))
throw new ServiceException("Forbidden", 403, "Account access denied.");
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 (!options.AllowedHosts.Contains(context.Request.Host.Value, StringComparer.OrdinalIgnoreCase))
throw new ServiceException("InvalidHost", 403, "Host not allowed.");
var origin = context.Request.Headers.Origin;
if (origin.Count != 0 && (origin.Count != 1 || !options.AllowedOrigins.Contains(origin[0], StringComparer.Ordinal)))
throw new ServiceException("InvalidOrigin", 403, "Origin not allowed.");
}
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.");
}
if (!context.Request.Cookies.TryGetValue(CookieName, out var id) || !sessions.TryGetValue(id, out var session))
throw new ServiceException("Unauthorized", 401, "Login required.");
if (session.Expires <= DateTimeOffset.UtcNow)
{
sessions.TryRemove(id, out _);
throw new ServiceException("Unauthorized", 401, "Session expired.");
}
var current = RequireCurrent(session.Identity);
if (!HttpMethods.IsGet(context.Request.Method) && !HttpMethods.IsHead(context.Request.Method) &&
(context.Request.Headers["X-CSRF-Token"].ToString() != session.Csrf ||
!options.AllowedOrigins.Contains(context.Request.Headers.Origin.ToString(), StringComparer.Ordinal)))
throw new ServiceException("CsrfRejected", 403, "CSRF token and same-origin request required.");
return current;
}
public object Login(HttpContext context, string token)
{
// 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.");
}
if (!options.AllowedOrigins.Contains(context.Request.Headers.Origin.ToString(), StringComparer.Ordinal))
throw new ServiceException("InvalidOrigin", 403, "Login requires an allowed Origin.");
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 };
}
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 = "/" });
}
}