Files
tiku-backend.net/Tiku.Infrastructure/Auth/AuthSessionStore.cs
xiong 290a0c7bd7
Some checks failed
ci / release-gate (push) Has been cancelled
feat(platform): harden governance and approval workflows
2026-08-03 11:43:18 +08:00

630 lines
28 KiB
C#

using System.Security.Cryptography;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
using Tiku.Application.Auth;
using Tiku.Application.Security;
using Tiku.Domain.Identity;
using Tiku.Domain.Operations;
using Tiku.Domain.Tenancy;
using Tiku.Infrastructure.Persistence;
using Tiku.Infrastructure.Security;
using ZLinq;
namespace Tiku.Infrastructure.Auth;
public sealed class AuthSessionStore(
TikuDbContext dbContext,
ITokenService tokenService,
IOptions<JwtOptions> options,
IAccessSecurityCache? configuredAccessSecurityCache = null,
IOptions<AuthorizationCacheOptions>? configuredCacheOptions = null,
IAuthorizationStateInvalidator? configuredStateInvalidator = null) : IAuthSessionStore
{
private readonly JwtOptions options = options.Value;
private readonly IAccessSecurityCache accessSecurityCache = configuredAccessSecurityCache ?? new NullAuthorizationCache();
private readonly AuthorizationCacheOptions cacheOptions = configuredCacheOptions?.Value ?? new AuthorizationCacheOptions();
private readonly IAuthorizationStateInvalidator stateInvalidator = configuredStateInvalidator ?? new NullAuthorizationStateInvalidator();
public string GenerateRefreshToken(AuthRealm realm, Guid? tenantId, Guid sessionId)
{
var realmCode = realm == AuthRealm.Tenant ? "t" : "p";
var tenant = tenantId?.ToString("N") ?? "-";
return $"v2.{realmCode}.{tenant}.{sessionId:N}.{Base64UrlEncoder.Encode(RandomNumberGenerator.GetBytes(64))}";
}
public bool TryParseRefreshToken(string refreshToken, out RefreshTokenLocator locator)
{
locator = default;
var parts = refreshToken?.Split('.', 5, StringSplitOptions.None) ?? [];
if (parts.Length != 5 || parts[0] != "v2" || parts[4].Length < 64 ||
!Guid.TryParseExact(parts[3], "N", out var sessionId))
{
return false;
}
if (parts[1] == "p" && parts[2] == "-")
{
locator = new RefreshTokenLocator(AuthRealm.Platform, null, sessionId);
return true;
}
if (parts[1] == "t" && Guid.TryParseExact(parts[2], "N", out var tenantId))
{
locator = new RefreshTokenLocator(AuthRealm.Tenant, tenantId, sessionId);
return true;
}
return false;
}
public string HashRefreshToken(string refreshToken) =>
Convert.ToHexString(SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(refreshToken))).ToLowerInvariant();
public async Task<AuthTokenPair> IssueAsync(
AuthSessionIssueRequest request,
CancellationToken cancellationToken = default)
{
ValidateRealm(request.Realm, request.TenantId);
var session = CreateSession(request, Guid.NewGuid());
var refreshToken = GenerateRefreshToken(session.Realm, session.TenantId, session.Id);
session.TokenHash = HashRefreshToken(refreshToken);
dbContext.AuthSessions.Add(session);
await dbContext.SaveChangesAsync(cancellationToken);
return CreatePair(request, session, refreshToken);
}
public async Task<AuthTokenPair> RotateAsync(
string refreshToken,
string? ipAddress,
string? userAgent,
CancellationToken cancellationToken = default)
{
if (!TryParseRefreshToken(refreshToken, out var locator))
{
throw new SessionRevokedException();
}
var tokenHash = HashRefreshToken(refreshToken);
var now = DateTimeOffset.UtcNow;
AuthorizationCacheTelemetry.PostgresFallback();
await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken);
var current = await dbContext.AuthSessions.SingleOrDefaultAsync(
item => item.Id == locator.SessionId && item.Realm == locator.Realm &&
item.TenantId == locator.TenantId && item.TokenHash == tokenHash,
cancellationToken);
if (current is null)
{
throw new SessionRevokedException();
}
if (current.RevokedAt.HasValue || current.ReplacedBySessionId.HasValue || current.ExpiresAt <= now)
{
await RevokeFamilyCoreAsync(current.TokenFamilyId, "refresh_token_reuse", now, cancellationToken);
await transaction.CommitAsync(cancellationToken);
throw new SessionRevokedException();
}
var user = await dbContext.Users.SingleOrDefaultAsync(item => item.Id == current.UserId, cancellationToken);
if (user is null || user.Status != UserStatus.Active ||
!string.Equals(user.SecurityStamp, current.SecurityStamp, StringComparison.Ordinal))
{
await RevokeFamilyCoreAsync(current.TokenFamilyId, "identity_state_changed", now, cancellationToken);
await transaction.CommitAsync(cancellationToken);
throw new SessionRevokedException();
}
try
{
await AssertRealmAccessAsync(
current.Realm, current.TenantId, current.UserId, cancellationToken);
}
catch (TenantAccessDeniedException)
{
await RevokeFamilyCoreAsync(current.TokenFamilyId, "realm_access_revoked", now, cancellationToken);
await transaction.CommitAsync(cancellationToken);
throw new SessionRevokedException();
}
var nextId = Guid.NewGuid();
var updated = await dbContext.AuthSessions
.Where(item => item.Id == current.Id && item.RevokedAt == null && item.ReplacedBySessionId == null)
.ExecuteUpdateAsync(setters => setters
.SetProperty(item => item.RevokedAt, now)
.SetProperty(item => item.RevokedReason, "rotated")
.SetProperty(item => item.ReplacedBySessionId, nextId), cancellationToken);
if (updated != 1)
{
await RevokeFamilyCoreAsync(current.TokenFamilyId, "refresh_token_reuse", now, cancellationToken);
await transaction.CommitAsync(cancellationToken);
throw new SessionRevokedException();
}
var request = new AuthSessionIssueRequest(
user.Id, user.Phone, user.Email, user.SecurityStamp ?? string.Empty,
current.Realm, current.TenantId, "refresh",
ipAddress, userAgent, current.TokenFamilyId, current.Id);
var next = CreateSession(request, nextId);
var nextToken = GenerateRefreshToken(next.Realm, next.TenantId, next.Id);
next.TokenHash = HashRefreshToken(nextToken);
dbContext.AuthSessions.Add(next);
await dbContext.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
await stateInvalidator.InvalidateSessionAsync(current.Id, cancellationToken);
return CreatePair(request, next, nextToken);
}
public async Task<AuthSessionValidationResult?> ValidateAccessSessionAsync(
Guid sessionId,
Guid userId,
AuthRealm realm,
Guid? tenantId,
CancellationToken cancellationToken = default)
{
var lookup = new AccessSecurityCacheLookup(sessionId, userId, realm, tenantId);
AccessSecurityCacheState? shadowState = null;
if (cacheOptions.Mode == AuthorizationCacheMode.Active && accessSecurityCache.IsConfigured)
{
try
{
var cached = await accessSecurityCache.GetAsync(lookup, cancellationToken);
if (cached is not null)
{
var platformVersionStale = realm == AuthRealm.Platform &&
cached.PlatformAccess!.AuthorizationVersion != cached.AuthorizationVersion!.Version;
if (!platformVersionStale)
{
return ValidateCached(cached, lookup);
}
}
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
// Redis is an acceleration layer; PostgreSQL remains authoritative.
}
}
else if (cacheOptions.Mode == AuthorizationCacheMode.Shadow && accessSecurityCache.IsConfigured)
{
try
{
shadowState = await accessSecurityCache.GetAsync(lookup, cancellationToken);
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
// Shadow failures never affect the PostgreSQL-authoritative decision.
}
}
var now = DateTimeOffset.UtcNow;
SessionValidationState? state;
try
{
state = await (
from session in dbContext.AuthSessions.AsNoTracking()
join user in dbContext.Users.AsNoTracking() on session.UserId equals user.Id
where session.Id == sessionId &&
session.UserId == userId &&
session.Realm == realm &&
session.TenantId == tenantId
select new SessionValidationState(
user.Status,
user.SecurityStamp!,
session.SecurityStamp,
realm != AuthRealm.Tenant ||
(tenantId != null &&
dbContext.Tenants.Any(item => item.Id == tenantId && item.Status == TenantStatus.Active) &&
dbContext.TenantMemberships.Any(item =>
item.TenantId == tenantId &&
item.UserId == userId &&
item.Status == MembershipStatus.Active)),
realm != AuthRealm.Platform ||
(from userRole in dbContext.PlatformBackendUserRoles
join role in dbContext.PlatformBackendRoles on userRole.RoleId equals role.Id
join binding in dbContext.PlatformBackendRolePermissions on role.Id equals binding.RoleId
join permission in dbContext.BackendPermissions on binding.PermissionCode equals permission.Code
where userRole.UserId == userId &&
role.Status == BackendRoleStatus.Active &&
(permission.Area == BackendPermissionArea.Platform || permission.Area == BackendPermissionArea.Both)
select permission.Id).Any(),
realm == AuthRealm.Tenant && tenantId != null
? dbContext.Tenants.Where(item => item.Id == tenantId).Select(item => (TenantStatus?)item.Status).FirstOrDefault()
: null,
realm == AuthRealm.Tenant && tenantId != null
? dbContext.TenantMemberships.Where(item => item.TenantId == tenantId && item.UserId == userId)
.Select(item => (MembershipStatus?)item.Status).FirstOrDefault()
: null,
dbContext.AuthorizationScopeVersions
.Where(item => item.Realm == realm && item.TenantId == tenantId)
.Select(item => (long?)item.Version).FirstOrDefault() ?? 1L,
session.ExpiresAt,
session.RevokedAt != null))
.SingleOrDefaultAsync(cancellationToken);
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
throw new AuthorizationSecurityUnavailableException(exception);
}
if (state is null ||
state.SessionRevoked ||
state.SessionExpiresAt <= now ||
state.UserStatus != UserStatus.Active ||
!string.Equals(state.UserSecurityStamp, state.SessionSecurityStamp, StringComparison.Ordinal) ||
!state.TenantAllowed ||
!state.PlatformAllowed)
{
if (state is not null &&
cacheOptions.Mode is AuthorizationCacheMode.Active or AuthorizationCacheMode.Shadow &&
accessSecurityCache.IsConfigured)
{
try
{
await accessSecurityCache.SetAsync(ToCacheState(
state, sessionId, userId, realm, tenantId), cancellationToken);
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
// A negative cache write failure does not change the denial decision.
}
}
if (shadowState is not null)
{
AuthorizationCacheTelemetry.ShadowCompared(ValidateCached(shadowState, lookup) is null);
}
return null;
}
var result = new AuthSessionValidationResult(userId, realm, tenantId, state.AuthorizationVersion);
if (shadowState is not null)
{
AuthorizationCacheTelemetry.ShadowCompared(ValidateCached(shadowState, lookup) == result);
}
if (cacheOptions.Mode is AuthorizationCacheMode.Active or AuthorizationCacheMode.Shadow && accessSecurityCache.IsConfigured)
{
try
{
await accessSecurityCache.SetAsync(
ToCacheState(state, sessionId, userId, realm, tenantId), cancellationToken);
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
// The database result is authoritative and remains usable.
}
}
return result;
}
private static AuthSessionValidationResult? ValidateCached(
AccessSecurityCacheState state, AccessSecurityCacheLookup lookup)
{
var session = state.Session!;
var user = state.User!;
var version = state.AuthorizationVersion!;
if (session.SessionId != lookup.SessionId || session.UserId != lookup.UserId ||
session.Realm != lookup.Realm || session.TenantId != lookup.TenantId ||
session.Revoked || session.ExpiresAt <= DateTimeOffset.UtcNow ||
user.UserId != lookup.UserId || user.Status != UserStatus.Active ||
!string.Equals(user.SecurityStamp, session.SecurityStamp, StringComparison.Ordinal) ||
version.Realm != lookup.Realm || version.TenantId != lookup.TenantId)
{
return null;
}
if (lookup.Realm == AuthRealm.Tenant &&
(state.Tenant!.Status != TenantStatus.Active || state.Membership!.Status != MembershipStatus.Active))
{
return null;
}
if (lookup.Realm == AuthRealm.Platform &&
(!state.PlatformAccess!.Allowed || state.PlatformAccess.AuthorizationVersion != version.Version))
{
return null;
}
return new AuthSessionValidationResult(lookup.UserId, lookup.Realm, lookup.TenantId, version.Version);
}
private static AccessSecurityCacheState ToCacheState(
SessionValidationState state, Guid sessionId, Guid userId, AuthRealm realm, Guid? tenantId) => new(
new CachedSessionSecurityState(sessionId, userId, realm, tenantId,
state.SessionSecurityStamp, state.SessionExpiresAt, state.SessionRevoked),
new CachedUserSecurityState(userId, state.UserStatus, state.UserSecurityStamp),
realm == AuthRealm.Tenant && state.TenantStatus.HasValue
? new CachedTenantSecurityState(tenantId!.Value, state.TenantStatus.Value)
: null,
realm == AuthRealm.Tenant && state.MembershipStatus.HasValue
? new CachedMembershipSecurityState(tenantId!.Value, userId, state.MembershipStatus.Value)
: null,
realm == AuthRealm.Platform
? new CachedPlatformAccessState(userId, state.AuthorizationVersion, state.PlatformAllowed)
: null,
new CachedAuthorizationVersion(realm, tenantId, state.AuthorizationVersion));
private sealed record SessionValidationState(
UserStatus UserStatus,
string UserSecurityStamp,
string SessionSecurityStamp,
bool TenantAllowed,
bool PlatformAllowed,
TenantStatus? TenantStatus,
MembershipStatus? MembershipStatus,
long AuthorizationVersion,
DateTimeOffset SessionExpiresAt,
bool SessionRevoked);
public async Task<AuthSessionValidationResult?> ResolveActiveSessionAsync(
Guid sessionId,
Guid userId,
CancellationToken cancellationToken = default)
{
var session = await dbContext.AuthSessions.AsNoTracking()
.Where(item => item.Id == sessionId && item.UserId == userId)
.Select(item => new { item.Realm, item.TenantId })
.SingleOrDefaultAsync(cancellationToken);
if (session is null)
{
return null;
}
return await ValidateAccessSessionAsync(
sessionId,
userId,
session.Realm,
session.TenantId,
cancellationToken);
}
public async Task RevokeFamilyAsync(string refreshToken, string reason, CancellationToken cancellationToken = default)
{
if (!TryParseRefreshToken(refreshToken, out var locator))
{
return;
}
var hash = HashRefreshToken(refreshToken);
var session = await dbContext.AuthSessions.AsNoTracking().SingleOrDefaultAsync(
item => item.Id == locator.SessionId && item.TokenHash == hash, cancellationToken);
if (session is not null)
{
await RevokeFamilyCoreAsync(session.TokenFamilyId, reason, DateTimeOffset.UtcNow, cancellationToken);
}
}
public async Task RevokeAllAsync(Guid userId, string reason, CancellationToken cancellationToken = default)
{
var now = DateTimeOffset.UtcNow;
var sessionIds = await dbContext.AuthSessions.AsNoTracking()
.Where(item => item.UserId == userId && item.RevokedAt == null)
.Select(item => item.Id).ToArrayAsync(cancellationToken);
var count = await dbContext.AuthSessions.Where(item => item.UserId == userId && item.RevokedAt == null)
.ExecuteUpdateAsync(setters => setters
.SetProperty(item => item.RevokedAt, DateTimeOffset.UtcNow)
.SetProperty(item => item.RevokedReason, reason), cancellationToken);
if (count > 0)
{
dbContext.AuditLogs.Add(new AuditLog
{
ActorUserId = userId,
Action = "auth.sessions.revoked_all",
TargetType = "user",
TargetId = userId.ToString(),
Details = System.Text.Json.JsonSerializer.SerializeToElement(new { reason, count, revokedAt = now })
});
await dbContext.SaveChangesAsync(cancellationToken);
foreach (var sessionId in sessionIds)
{
await stateInvalidator.InvalidateSessionAsync(sessionId, cancellationToken);
}
await stateInvalidator.InvalidateUserAsync(userId, cancellationToken);
}
}
public async Task RevokeRealmAsync(
Guid userId,
AuthRealm realm,
Guid? tenantId,
string reason,
CancellationToken cancellationToken = default)
{
ValidateRealm(realm, tenantId);
var now = DateTimeOffset.UtcNow;
var sessionIds = await dbContext.AuthSessions.AsNoTracking()
.Where(item => item.UserId == userId && item.Realm == realm && item.TenantId == tenantId && item.RevokedAt == null)
.Select(item => item.Id).ToArrayAsync(cancellationToken);
var count = await dbContext.AuthSessions
.Where(item => item.UserId == userId && item.Realm == realm && item.TenantId == tenantId && item.RevokedAt == null)
.ExecuteUpdateAsync(setters => setters
.SetProperty(item => item.RevokedAt, now)
.SetProperty(item => item.RevokedReason, reason), cancellationToken);
if (count > 0)
{
dbContext.AuditLogs.Add(new AuditLog
{
TenantId = tenantId,
ActorUserId = userId,
Action = "auth.sessions.realm_revoked",
TargetType = "user",
TargetId = userId.ToString(),
Details = System.Text.Json.JsonSerializer.SerializeToElement(new { realm, reason, count, revokedAt = now })
});
await dbContext.SaveChangesAsync(cancellationToken);
foreach (var sessionId in sessionIds)
{
await stateInvalidator.InvalidateSessionAsync(sessionId, cancellationToken);
}
}
}
public async Task<IReadOnlyCollection<AuthSessionSummary>> ListActiveAsync(
Guid userId,
Guid currentSessionId,
CancellationToken cancellationToken = default)
{
var current = await dbContext.AuthSessions.AsNoTracking()
.SingleOrDefaultAsync(item => item.Id == currentSessionId && item.UserId == userId, cancellationToken)
?? throw new SessionRevokedException();
var now = DateTimeOffset.UtcNow;
var sessions = await dbContext.AuthSessions.AsNoTracking()
.Where(item => item.UserId == userId && item.Realm == current.Realm && item.TenantId == current.TenantId)
.OrderBy(item => item.CreatedAt)
.ToArrayAsync(cancellationToken);
return sessions
.AsValueEnumerable()
.GroupBy(item => item.TokenFamilyId)
.Select(group => new { All = group.ToArray(), Active = group.LastOrDefault(item => item.RevokedAt == null && item.ExpiresAt > now) })
.Where(value => value.Active is not null)
.Select(value => new AuthSessionSummary(
value.Active!.TokenFamilyId,
value.Active.Realm,
value.Active.TenantId,
value.Active.Provider,
value.All.Min(item => item.CreatedAt),
value.Active.CreatedAt,
value.Active.ExpiresAt,
MaskIpAddress(value.Active.IpAddress),
value.Active.UserAgent,
value.Active.Id == currentSessionId))
.OrderByDescending(item => item.IsCurrent)
.ThenByDescending(item => item.LastRotatedAt)
.ToArray();
}
public async Task RevokeOwnedFamilyAsync(
Guid userId,
Guid currentSessionId,
Guid sessionFamilyId,
CancellationToken cancellationToken = default)
{
var current = await dbContext.AuthSessions.AsNoTracking()
.SingleOrDefaultAsync(item => item.Id == currentSessionId && item.UserId == userId, cancellationToken)
?? throw new SessionRevokedException();
if (current.TokenFamilyId == sessionFamilyId)
{
throw new CurrentAuthSessionCannotBeRevokedException();
}
var owned = await dbContext.AuthSessions.AsNoTracking().AnyAsync(
item => item.UserId == userId && item.TokenFamilyId == sessionFamilyId &&
item.Realm == current.Realm && item.TenantId == current.TenantId,
cancellationToken);
if (!owned)
{
throw new AuthSessionNotFoundException();
}
await RevokeFamilyCoreAsync(sessionFamilyId, "user_revoked_device", DateTimeOffset.UtcNow, cancellationToken);
}
private AuthSession CreateSession(AuthSessionIssueRequest request, Guid sessionId) => new()
{
Id = sessionId,
Realm = request.Realm,
TenantId = request.TenantId,
UserId = request.UserId,
TokenFamilyId = request.TokenFamilyId ?? sessionId,
ParentSessionId = request.ParentSessionId,
SecurityStamp = request.SecurityStamp,
Provider = request.Provider,
ExpiresAt = DateTimeOffset.UtcNow.AddDays(options.RefreshTokenDays),
IpAddress = request.IpAddress,
UserAgent = request.UserAgent
};
private AuthTokenPair CreatePair(AuthSessionIssueRequest request, AuthSession session, string refreshToken)
{
var access = tokenService.CreateAccessToken(
request.UserId, session.Id, request.Phone, request.Email,
request.Realm, request.TenantId);
return new AuthTokenPair(access.Token, refreshToken, access.ExpiresAt, session.ExpiresAt);
}
private async Task AssertRealmAccessAsync(
AuthRealm realm,
Guid? tenantId,
Guid userId,
CancellationToken cancellationToken)
{
if (realm == AuthRealm.Tenant && tenantId.HasValue)
{
var active = await dbContext.Tenants.AnyAsync(item => item.Id == tenantId && item.Status == TenantStatus.Active, cancellationToken) &&
await dbContext.TenantMemberships.AnyAsync(item => item.TenantId == tenantId && item.UserId == userId && item.Status == MembershipStatus.Active, cancellationToken);
if (active)
{
return;
}
}
else if (realm == AuthRealm.Platform)
{
var active = await (
from userRole in dbContext.PlatformBackendUserRoles
join role in dbContext.PlatformBackendRoles on userRole.RoleId equals role.Id
join binding in dbContext.PlatformBackendRolePermissions on role.Id equals binding.RoleId
join permission in dbContext.BackendPermissions on binding.PermissionCode equals permission.Code
where userRole.UserId == userId && role.Status == BackendRoleStatus.Active &&
(permission.Area == BackendPermissionArea.Platform || permission.Area == BackendPermissionArea.Both)
select permission.Id).AnyAsync(cancellationToken);
if (active) return;
}
throw new TenantAccessDeniedException();
}
private async Task<int> RevokeFamilyCoreAsync(Guid familyId, string reason, DateTimeOffset now, CancellationToken cancellationToken)
{
var sessionIds = await dbContext.AuthSessions.AsNoTracking()
.Where(item => item.TokenFamilyId == familyId && item.RevokedAt == null)
.Select(item => item.Id).ToArrayAsync(cancellationToken);
var owner = await dbContext.AuthSessions.AsNoTracking()
.Where(item => item.TokenFamilyId == familyId)
.Select(item => new { item.UserId, item.TenantId })
.FirstOrDefaultAsync(cancellationToken);
var count = await dbContext.AuthSessions.Where(item => item.TokenFamilyId == familyId && item.RevokedAt == null)
.ExecuteUpdateAsync(setters => setters
.SetProperty(item => item.RevokedAt, now)
.SetProperty(item => item.RevokedReason, reason), cancellationToken);
if (count > 0 && owner is not null)
{
dbContext.AuditLogs.Add(new AuditLog
{
TenantId = owner.TenantId,
ActorUserId = owner.UserId,
Action = "auth.session_family.revoked",
TargetType = "auth_session_family",
TargetId = familyId.ToString(),
Details = System.Text.Json.JsonSerializer.SerializeToElement(new { reason, count, revokedAt = now })
});
await dbContext.SaveChangesAsync(cancellationToken);
foreach (var sessionId in sessionIds)
{
await stateInvalidator.InvalidateSessionAsync(sessionId, cancellationToken);
}
}
return count;
}
private static void ValidateRealm(AuthRealm realm, Guid? tenantId)
{
if ((realm == AuthRealm.Tenant) != tenantId.HasValue)
{
throw new ArgumentException("Tenant sessions require a tenant and platform sessions must not have one.");
}
}
private static string? MaskIpAddress(string? value)
{
if (!System.Net.IPAddress.TryParse(value, out var address))
{
return null;
}
var bytes = address.GetAddressBytes();
if (address.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork)
{
bytes[3] = 0;
return $"{new System.Net.IPAddress(bytes)}/24";
}
Array.Clear(bytes, 8, bytes.Length - 8);
return $"{new System.Net.IPAddress(bytes)}/64";
}
}