rework authentication
This commit is contained in:
@@ -509,7 +509,7 @@ public partial class MareHub
|
|||||||
_logger.LogCallInfo(MareHubLogger.Args(gid, uid, "Success"));
|
_logger.LogCallInfo(MareHubLogger.Args(gid, uid, "Success"));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Authorize(AuthenticationSchemes = SecretKeyGrpcAuthenticationHandler.AuthScheme)]
|
[Authorize(Policy = "Identified")]
|
||||||
public async Task<List<BannedGroupUserDto>> GroupGetBannedUsers(string gid)
|
public async Task<List<BannedGroupUserDto>> GroupGetBannedUsers(string gid)
|
||||||
{
|
{
|
||||||
_logger.LogCallInfo(MareHubLogger.Args(gid));
|
_logger.LogCallInfo(MareHubLogger.Args(gid));
|
||||||
|
|||||||
@@ -38,8 +38,6 @@ public partial class MareHub
|
|||||||
await Task.Delay(1000).ConfigureAwait(false);
|
await Task.Delay(1000).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
await _authServiceClient.RemoveAuthAsync(new UidMessage() { Uid = userid }).ConfigureAwait(false);
|
|
||||||
|
|
||||||
_dbContext.ClientPairs.RemoveRange(ownPairData);
|
_dbContext.ClientPairs.RemoveRange(ownPairData);
|
||||||
await _dbContext.SaveChangesAsync().ConfigureAwait(false);
|
await _dbContext.SaveChangesAsync().ConfigureAwait(false);
|
||||||
var otherPairData = await _dbContext.ClientPairs.Include(u => u.User)
|
var otherPairData = await _dbContext.ClientPairs.Include(u => u.User)
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ namespace MareSynchronosServer.Hubs;
|
|||||||
public partial class MareHub : Hub<IMareHub>, IMareHub
|
public partial class MareHub : Hub<IMareHub>, IMareHub
|
||||||
{
|
{
|
||||||
private readonly MareMetrics _mareMetrics;
|
private readonly MareMetrics _mareMetrics;
|
||||||
private readonly AuthService.AuthServiceClient _authServiceClient;
|
|
||||||
private readonly FileService.FileServiceClient _fileServiceClient;
|
private readonly FileService.FileServiceClient _fileServiceClient;
|
||||||
private readonly SystemInfoService _systemInfoService;
|
private readonly SystemInfoService _systemInfoService;
|
||||||
private readonly IHttpContextAccessor _contextAccessor;
|
private readonly IHttpContextAccessor _contextAccessor;
|
||||||
@@ -33,12 +32,11 @@ public partial class MareHub : Hub<IMareHub>, IMareHub
|
|||||||
private readonly int _maxJoinedGroupsByUser;
|
private readonly int _maxJoinedGroupsByUser;
|
||||||
private readonly int _maxGroupUserCount;
|
private readonly int _maxGroupUserCount;
|
||||||
|
|
||||||
public MareHub(MareMetrics mareMetrics, AuthService.AuthServiceClient authServiceClient, FileService.FileServiceClient fileServiceClient,
|
public MareHub(MareMetrics mareMetrics, FileService.FileServiceClient fileServiceClient,
|
||||||
MareDbContext mareDbContext, ILogger<MareHub> logger, SystemInfoService systemInfoService, IConfiguration configuration, IHttpContextAccessor contextAccessor,
|
MareDbContext mareDbContext, ILogger<MareHub> logger, SystemInfoService systemInfoService, IConfiguration configuration, IHttpContextAccessor contextAccessor,
|
||||||
GrpcClientIdentificationService clientIdentService)
|
GrpcClientIdentificationService clientIdentService)
|
||||||
{
|
{
|
||||||
_mareMetrics = mareMetrics;
|
_mareMetrics = mareMetrics;
|
||||||
_authServiceClient = authServiceClient;
|
|
||||||
_fileServiceClient = fileServiceClient;
|
_fileServiceClient = fileServiceClient;
|
||||||
_systemInfoService = systemInfoService;
|
_systemInfoService = systemInfoService;
|
||||||
var config = configuration.GetRequiredSection("MareSynchronos");
|
var config = configuration.GetRequiredSection("MareSynchronos");
|
||||||
|
|||||||
@@ -90,17 +90,6 @@ public class Startup
|
|||||||
MetricsAPI.GaugeGroupPairsPaused
|
MetricsAPI.GaugeGroupPairsPaused
|
||||||
}));
|
}));
|
||||||
|
|
||||||
services.AddGrpcClient<AuthService.AuthServiceClient>(c =>
|
|
||||||
{
|
|
||||||
c.Address = new Uri(mareConfig.GetValue<string>("ServiceAddress"));
|
|
||||||
}).ConfigureChannel(c =>
|
|
||||||
{
|
|
||||||
c.ServiceConfig = new ServiceConfig { MethodConfigs = { noRetryConfig } };
|
|
||||||
c.HttpHandler = new SocketsHttpHandler()
|
|
||||||
{
|
|
||||||
EnableMultipleHttp2Connections = true
|
|
||||||
};
|
|
||||||
});
|
|
||||||
services.AddGrpcClient<FileService.FileServiceClient>(c =>
|
services.AddGrpcClient<FileService.FileServiceClient>(c =>
|
||||||
{
|
{
|
||||||
c.Address = new Uri(mareConfig.GetValue<string>("StaticFileServiceAddress"));
|
c.Address = new Uri(mareConfig.GetValue<string>("StaticFileServiceAddress"));
|
||||||
@@ -120,10 +109,9 @@ public class Startup
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
services.AddSingleton<GrpcAuthenticationService>();
|
services.AddSingleton<SecretKeyAuthenticatorService>();
|
||||||
services.AddSingleton<GrpcClientIdentificationService>();
|
services.AddSingleton<GrpcClientIdentificationService>();
|
||||||
services.AddTransient<IAuthorizationHandler, UserRequirementHandler>();
|
services.AddTransient<IAuthorizationHandler, UserRequirementHandler>();
|
||||||
services.AddHostedService(p => p.GetService<GrpcAuthenticationService>());
|
|
||||||
services.AddHostedService(p => p.GetService<GrpcClientIdentificationService>());
|
services.AddHostedService(p => p.GetService<GrpcClientIdentificationService>());
|
||||||
|
|
||||||
services.AddDbContextPool<MareDbContext>(options =>
|
services.AddDbContextPool<MareDbContext>(options =>
|
||||||
@@ -136,17 +124,17 @@ public class Startup
|
|||||||
options.EnableThreadSafetyChecks(false);
|
options.EnableThreadSafetyChecks(false);
|
||||||
}, mareConfig.GetValue("DbContextPoolSize", 1024));
|
}, mareConfig.GetValue("DbContextPoolSize", 1024));
|
||||||
|
|
||||||
services.AddAuthentication(SecretKeyGrpcAuthenticationHandler.AuthScheme)
|
services.AddAuthentication(SecretKeyAuthenticationHandler.AuthScheme)
|
||||||
.AddScheme<AuthenticationSchemeOptions, SecretKeyGrpcAuthenticationHandler>(SecretKeyGrpcAuthenticationHandler.AuthScheme, options => { options.Validate(); });
|
.AddScheme<AuthenticationSchemeOptions, SecretKeyAuthenticationHandler>(SecretKeyAuthenticationHandler.AuthScheme, options => { options.Validate(); });
|
||||||
|
|
||||||
services.AddAuthorization(options =>
|
services.AddAuthorization(options =>
|
||||||
{
|
{
|
||||||
options.DefaultPolicy = new AuthorizationPolicyBuilder()
|
options.DefaultPolicy = new AuthorizationPolicyBuilder()
|
||||||
.AddAuthenticationSchemes(SecretKeyGrpcAuthenticationHandler.AuthScheme)
|
.AddAuthenticationSchemes(SecretKeyAuthenticationHandler.AuthScheme)
|
||||||
.RequireAuthenticatedUser().Build();
|
.RequireAuthenticatedUser().Build();
|
||||||
options.AddPolicy("Authenticated", policy =>
|
options.AddPolicy("Authenticated", policy =>
|
||||||
{
|
{
|
||||||
policy.AddAuthenticationSchemes(SecretKeyGrpcAuthenticationHandler.AuthScheme);
|
policy.AddAuthenticationSchemes(SecretKeyAuthenticationHandler.AuthScheme);
|
||||||
policy.RequireAuthenticatedUser();
|
policy.RequireAuthenticatedUser();
|
||||||
});
|
});
|
||||||
options.AddPolicy("Identified", policy =>
|
options.AddPolicy("Identified", policy =>
|
||||||
|
|||||||
@@ -1,146 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Concurrent;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using MareSynchronosShared.Data;
|
|
||||||
using MareSynchronosShared.Metrics;
|
|
||||||
using MareSynchronosShared.Protos;
|
|
||||||
using MareSynchronosShared.Utils;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Microsoft.Extensions.Configuration;
|
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
|
|
||||||
namespace MareSynchronosServices.Authentication;
|
|
||||||
|
|
||||||
public class SecretKeyAuthenticationHandler
|
|
||||||
{
|
|
||||||
private readonly ILogger<SecretKeyAuthenticationHandler> _logger;
|
|
||||||
private readonly MareMetrics _metrics;
|
|
||||||
private const string Unauthorized = "Unauthorized";
|
|
||||||
private readonly ConcurrentDictionary<string, string> _cachedAuthorizations = new();
|
|
||||||
private readonly ConcurrentDictionary<string, FailedAuthorization?> _failedAuthorizations = new();
|
|
||||||
private readonly int _failedAttemptsForTempBan;
|
|
||||||
private readonly int _tempBanMinutes;
|
|
||||||
private readonly List<string> _whitelistedIps = new();
|
|
||||||
|
|
||||||
public void ClearUnauthorizedUsers()
|
|
||||||
{
|
|
||||||
foreach (var item in _cachedAuthorizations.ToArray())
|
|
||||||
{
|
|
||||||
if (item.Value == Unauthorized)
|
|
||||||
{
|
|
||||||
_cachedAuthorizations.TryRemove(item.Key, out _);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public void RemoveAuthentication(string uid)
|
|
||||||
{
|
|
||||||
var authorization = _cachedAuthorizations.Where(u => u.Value == uid);
|
|
||||||
if (authorization.Any())
|
|
||||||
{
|
|
||||||
_cachedAuthorizations.Remove(authorization.First().Key, out _);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<AuthReply> AuthenticateAsync(MareDbContext mareDbContext, string ip, string secretKey)
|
|
||||||
{
|
|
||||||
_metrics.IncCounter(MetricsAPI.CounterAuthenticationRequests);
|
|
||||||
|
|
||||||
if (string.IsNullOrEmpty(secretKey))
|
|
||||||
{
|
|
||||||
_metrics.IncCounter(MetricsAPI.CounterAuthenticationFailures);
|
|
||||||
return new AuthReply() { Success = false, Uid = new UidMessage() { Uid = string.Empty } };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (_failedAuthorizations.TryGetValue(ip, out var existingFailedAuthorization) && existingFailedAuthorization.FailedAttempts > _failedAttemptsForTempBan)
|
|
||||||
{
|
|
||||||
_metrics.IncCounter(MetricsAPI.CounterAuthenticationCacheHits);
|
|
||||||
_metrics.IncCounter(MetricsAPI.CounterAuthenticationFailures);
|
|
||||||
|
|
||||||
if (existingFailedAuthorization.ResetTask == null)
|
|
||||||
{
|
|
||||||
_logger.LogWarning("TempBan {ip} for authorization spam", ip);
|
|
||||||
|
|
||||||
existingFailedAuthorization.ResetTask = Task.Run(async () =>
|
|
||||||
{
|
|
||||||
await Task.Delay(TimeSpan.FromMinutes(_tempBanMinutes)).ConfigureAwait(false);
|
|
||||||
|
|
||||||
}).ContinueWith((t) =>
|
|
||||||
{
|
|
||||||
_failedAuthorizations.Remove(ip, out _);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return new AuthReply() { Success = false, Uid = new UidMessage() { Uid = string.Empty } };
|
|
||||||
}
|
|
||||||
|
|
||||||
var hashedHeader = StringUtils.Sha256String(secretKey);
|
|
||||||
|
|
||||||
bool fromCache = _cachedAuthorizations.TryGetValue(hashedHeader, out string uid);
|
|
||||||
|
|
||||||
if (fromCache && !string.IsNullOrEmpty(uid))
|
|
||||||
{
|
|
||||||
_metrics.IncCounter(MetricsAPI.CounterAuthenticationCacheHits);
|
|
||||||
|
|
||||||
if (uid == Unauthorized)
|
|
||||||
{
|
|
||||||
return AuthenticationFailure(ip);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
uid = (await mareDbContext.Auth.AsNoTracking()
|
|
||||||
.FirstOrDefaultAsync(m => m.HashedKey == hashedHeader).ConfigureAwait(false))?.UserUID;
|
|
||||||
|
|
||||||
if (string.IsNullOrEmpty(uid))
|
|
||||||
{
|
|
||||||
_cachedAuthorizations[hashedHeader] = Unauthorized;
|
|
||||||
|
|
||||||
return AuthenticationFailure(ip);
|
|
||||||
}
|
|
||||||
|
|
||||||
_cachedAuthorizations[hashedHeader] = uid;
|
|
||||||
}
|
|
||||||
|
|
||||||
_metrics.IncCounter(MetricsAPI.CounterAuthenticationSuccesses);
|
|
||||||
|
|
||||||
return new AuthReply() { Success = true, Uid = new UidMessage() { Uid = uid } };
|
|
||||||
}
|
|
||||||
|
|
||||||
private AuthReply AuthenticationFailure(string ip)
|
|
||||||
{
|
|
||||||
_metrics.IncCounter(MetricsAPI.CounterAuthenticationFailures);
|
|
||||||
|
|
||||||
_logger.LogWarning("Failed authorization from {ip}", ip);
|
|
||||||
if (!_whitelistedIps.Any(w => ip.Contains(w)))
|
|
||||||
{
|
|
||||||
if (_failedAuthorizations.TryGetValue(ip, out var auth))
|
|
||||||
{
|
|
||||||
auth.IncreaseFailedAttempts();
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
_failedAuthorizations[ip] = new FailedAuthorization();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return new AuthReply() { Success = false, Uid = new UidMessage() { Uid = string.Empty } };
|
|
||||||
}
|
|
||||||
|
|
||||||
public SecretKeyAuthenticationHandler(IConfiguration configuration, ILogger<SecretKeyAuthenticationHandler> logger, MareMetrics metrics)
|
|
||||||
{
|
|
||||||
_logger = logger;
|
|
||||||
_metrics = metrics;
|
|
||||||
var config = configuration.GetRequiredSection("MareSynchronos");
|
|
||||||
_failedAttemptsForTempBan = config.GetValue<int>("FailedAuthForTempBan", 5);
|
|
||||||
logger.LogInformation("FailedAuthForTempBan: {num}", _failedAttemptsForTempBan);
|
|
||||||
_tempBanMinutes = config.GetValue<int>("TempBanDurationInMinutes", 30);
|
|
||||||
logger.LogInformation("TempBanMinutes: {num}", _tempBanMinutes);
|
|
||||||
_whitelistedIps = config.GetSection("WhitelistedIps").Get<List<string>>();
|
|
||||||
foreach (var ip in _whitelistedIps)
|
|
||||||
{
|
|
||||||
logger.LogInformation("Whitelisted IP: " + ip);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
using MareSynchronosServices.Authentication;
|
using MareSynchronosShared.Data;
|
||||||
using MareSynchronosShared.Data;
|
|
||||||
using MareSynchronosShared.Metrics;
|
using MareSynchronosShared.Metrics;
|
||||||
using MareSynchronosShared.Models;
|
using MareSynchronosShared.Models;
|
||||||
using MareSynchronosShared.Utils;
|
using MareSynchronosShared.Utils;
|
||||||
@@ -19,16 +18,14 @@ namespace MareSynchronosServices;
|
|||||||
public class CleanupService : IHostedService, IDisposable
|
public class CleanupService : IHostedService, IDisposable
|
||||||
{
|
{
|
||||||
private readonly MareMetrics metrics;
|
private readonly MareMetrics metrics;
|
||||||
private readonly SecretKeyAuthenticationHandler _authService;
|
|
||||||
private readonly ILogger<CleanupService> _logger;
|
private readonly ILogger<CleanupService> _logger;
|
||||||
private readonly IServiceProvider _services;
|
private readonly IServiceProvider _services;
|
||||||
private readonly IConfiguration _configuration;
|
private readonly IConfiguration _configuration;
|
||||||
private Timer? _timer;
|
private Timer? _timer;
|
||||||
|
|
||||||
public CleanupService(MareMetrics metrics, SecretKeyAuthenticationHandler authService, ILogger<CleanupService> logger, IServiceProvider services, IConfiguration configuration)
|
public CleanupService(MareMetrics metrics, ILogger<CleanupService> logger, IServiceProvider services, IConfiguration configuration)
|
||||||
{
|
{
|
||||||
this.metrics = metrics;
|
this.metrics = metrics;
|
||||||
_authService = authService;
|
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
_services = services;
|
_services = services;
|
||||||
_configuration = configuration.GetRequiredSection("MareSynchronos");
|
_configuration = configuration.GetRequiredSection("MareSynchronos");
|
||||||
@@ -119,8 +116,6 @@ public class CleanupService : IHostedService, IDisposable
|
|||||||
_logger.LogWarning(ex, "Error during Temp Invite purge");
|
_logger.LogWarning(ex, "Error during Temp Invite purge");
|
||||||
}
|
}
|
||||||
|
|
||||||
_authService.ClearUnauthorizedUsers();
|
|
||||||
|
|
||||||
_logger.LogInformation($"Cleanup complete");
|
_logger.LogInformation($"Cleanup complete");
|
||||||
|
|
||||||
dbContext.SaveChanges();
|
dbContext.SaveChanges();
|
||||||
@@ -137,8 +132,6 @@ public class CleanupService : IHostedService, IDisposable
|
|||||||
dbContext.Remove(lodestone);
|
dbContext.Remove(lodestone);
|
||||||
}
|
}
|
||||||
|
|
||||||
_authService.RemoveAuthentication(user.UID);
|
|
||||||
|
|
||||||
var auth = dbContext.Auth.Single(a => a.UserUID == user.UID);
|
var auth = dbContext.Auth.Single(a => a.UserUID == user.UID);
|
||||||
|
|
||||||
var userFiles = dbContext.Files.Where(f => f.Uploaded && f.Uploader.UID == user.UID).ToList();
|
var userFiles = dbContext.Files.Where(f => f.Uploaded && f.Uploader.UID == user.UID).ToList();
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ using Microsoft.EntityFrameworkCore;
|
|||||||
using Discord.WebSocket;
|
using Discord.WebSocket;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using Prometheus;
|
using Prometheus;
|
||||||
using MareSynchronosServices.Authentication;
|
|
||||||
using MareSynchronosShared.Models;
|
using MareSynchronosShared.Models;
|
||||||
using MareSynchronosServices.Identity;
|
using MareSynchronosServices.Identity;
|
||||||
using MareSynchronosShared.Metrics;
|
using MareSynchronosShared.Metrics;
|
||||||
@@ -304,9 +303,6 @@ public class MareModule : InteractionModuleBase
|
|||||||
|
|
||||||
await db.Auth.AddAsync(auth).ConfigureAwait(false);
|
await db.Auth.AddAsync(auth).ConfigureAwait(false);
|
||||||
await db.SaveChangesAsync().ConfigureAwait(false);
|
await db.SaveChangesAsync().ConfigureAwait(false);
|
||||||
|
|
||||||
var authHandler = scope.ServiceProvider.GetService<SecretKeyAuthenticationHandler>();
|
|
||||||
authHandler.RemoveAuthentication(existingLodestoneAuth.User.UID);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,45 +0,0 @@
|
|||||||
using Grpc.Core;
|
|
||||||
using MareSynchronosServices.Authentication;
|
|
||||||
using MareSynchronosShared.Data;
|
|
||||||
using MareSynchronosShared.Protos;
|
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
|
|
||||||
namespace MareSynchronosServices.Services;
|
|
||||||
|
|
||||||
internal class AuthenticationService : AuthService.AuthServiceBase
|
|
||||||
{
|
|
||||||
private readonly ILogger<AuthenticationService> _logger;
|
|
||||||
private readonly MareDbContext _dbContext;
|
|
||||||
private readonly SecretKeyAuthenticationHandler _authHandler;
|
|
||||||
|
|
||||||
public AuthenticationService(ILogger<AuthenticationService> logger, MareDbContext dbContext, SecretKeyAuthenticationHandler authHandler)
|
|
||||||
{
|
|
||||||
_logger = logger;
|
|
||||||
_dbContext = dbContext;
|
|
||||||
_authHandler = authHandler;
|
|
||||||
}
|
|
||||||
|
|
||||||
public override async Task Authorize(IAsyncStreamReader<AuthRequest> requestStream, IServerStreamWriter<AuthReply> responseStream, ServerCallContext context)
|
|
||||||
{
|
|
||||||
await foreach (var input in requestStream.ReadAllAsync(context.CancellationToken).ConfigureAwait(false))
|
|
||||||
{
|
|
||||||
var response = await _authHandler.AuthenticateAsync(_dbContext, input.Ip, input.SecretKey).ConfigureAwait(false);
|
|
||||||
await responseStream.WriteAsync(response, context.CancellationToken).ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public override Task<Empty> RemoveAuth(UidMessage request, ServerCallContext context)
|
|
||||||
{
|
|
||||||
_logger.LogInformation("Removing Authentication for {uid}", request.Uid);
|
|
||||||
_authHandler.RemoveAuthentication(request.Uid);
|
|
||||||
return Task.FromResult(new Empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
public override Task<Empty> ClearUnauthorized(Empty request, ServerCallContext context)
|
|
||||||
{
|
|
||||||
_logger.LogInformation("Clearing unauthorized users");
|
|
||||||
_authHandler.ClearUnauthorizedUsers();
|
|
||||||
return Task.FromResult(new Empty());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,4 @@
|
|||||||
using MareSynchronosServices.Authentication;
|
|
||||||
using MareSynchronosServices.Discord;
|
using MareSynchronosServices.Discord;
|
||||||
using MareSynchronosServices.Services;
|
|
||||||
using MareSynchronosShared.Data;
|
using MareSynchronosShared.Data;
|
||||||
using MareSynchronosShared.Metrics;
|
using MareSynchronosShared.Metrics;
|
||||||
using Microsoft.AspNetCore.Builder;
|
using Microsoft.AspNetCore.Builder;
|
||||||
@@ -45,7 +43,6 @@ public class Startup
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
services.AddTransient(_ => Configuration);
|
services.AddTransient(_ => Configuration);
|
||||||
services.AddSingleton<SecretKeyAuthenticationHandler>();
|
|
||||||
services.AddSingleton<DiscordBotServices>();
|
services.AddSingleton<DiscordBotServices>();
|
||||||
services.AddSingleton<IdentityHandler>();
|
services.AddSingleton<IdentityHandler>();
|
||||||
services.AddSingleton<CleanupService>();
|
services.AddSingleton<CleanupService>();
|
||||||
@@ -63,7 +60,6 @@ public class Startup
|
|||||||
|
|
||||||
app.UseEndpoints(endpoints =>
|
app.UseEndpoints(endpoints =>
|
||||||
{
|
{
|
||||||
endpoints.MapGrpcService<AuthenticationService>();
|
|
||||||
endpoints.MapGrpcService<IdentityService>();
|
endpoints.MapGrpcService<IdentityService>();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
namespace MareSynchronosShared.Authentication;
|
||||||
|
|
||||||
|
internal record SecretKeyAuthReply(bool Success, string? Uid);
|
||||||
@@ -1,27 +1,27 @@
|
|||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using System.Text.Encodings.Web;
|
using System.Text.Encodings.Web;
|
||||||
using MareSynchronosServer;
|
using MareSynchronosServer;
|
||||||
using MareSynchronosShared.Services;
|
using MareSynchronosShared.Data;
|
||||||
using Microsoft.AspNetCore.Authentication;
|
using Microsoft.AspNetCore.Authentication;
|
||||||
using Microsoft.AspNetCore.Http;
|
using Microsoft.AspNetCore.Http;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using ISystemClock = Microsoft.AspNetCore.Authentication.ISystemClock;
|
|
||||||
|
|
||||||
namespace MareSynchronosShared.Authentication;
|
namespace MareSynchronosShared.Authentication;
|
||||||
|
|
||||||
public class SecretKeyGrpcAuthenticationHandler : AuthenticationHandler<AuthenticationSchemeOptions>
|
public class SecretKeyAuthenticationHandler : AuthenticationHandler<AuthenticationSchemeOptions>
|
||||||
{
|
{
|
||||||
public const string AuthScheme = "SecretKeyGrpcAuth";
|
public const string AuthScheme = "SecretKeyGrpcAuth";
|
||||||
|
|
||||||
private readonly GrpcAuthenticationService _grpcAuthService;
|
private readonly MareDbContext _mareDbContext;
|
||||||
private readonly IHttpContextAccessor _accessor;
|
private readonly IHttpContextAccessor _accessor;
|
||||||
|
private readonly SecretKeyAuthenticatorService secretKeyAuthenticatorService;
|
||||||
|
|
||||||
public SecretKeyGrpcAuthenticationHandler(IHttpContextAccessor accessor, GrpcAuthenticationService authClient,
|
public SecretKeyAuthenticationHandler(IHttpContextAccessor accessor, SecretKeyAuthenticatorService secretKeyAuthenticatorService,
|
||||||
IOptionsMonitor<AuthenticationSchemeOptions> options, ILoggerFactory logger, UrlEncoder encoder, ISystemClock clock) : base(options, logger, encoder, clock)
|
IOptionsMonitor<AuthenticationSchemeOptions> options, ILoggerFactory logger, UrlEncoder encoder, ISystemClock clock) : base(options, logger, encoder, clock)
|
||||||
{
|
{
|
||||||
this._grpcAuthService = authClient;
|
|
||||||
_accessor = accessor;
|
_accessor = accessor;
|
||||||
|
this.secretKeyAuthenticatorService = secretKeyAuthenticatorService;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
|
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
|
||||||
@@ -33,22 +33,20 @@ public class SecretKeyGrpcAuthenticationHandler : AuthenticationHandler<Authenti
|
|||||||
|
|
||||||
var ip = _accessor.GetIpAddress();
|
var ip = _accessor.GetIpAddress();
|
||||||
|
|
||||||
var authResult = await _grpcAuthService.AuthorizeAsync(ip, authHeader).ConfigureAwait(false);
|
var authResult = await secretKeyAuthenticatorService.AuthorizeAsync(ip, authHeader).ConfigureAwait(false);
|
||||||
|
|
||||||
if (!authResult.Success)
|
if (!authResult.Success)
|
||||||
{
|
{
|
||||||
return AuthenticateResult.Fail("Failed Authorization");
|
return AuthenticateResult.Fail("Failed Authorization");
|
||||||
}
|
}
|
||||||
|
|
||||||
var uid = authResult.Uid;
|
|
||||||
|
|
||||||
var claims = new List<Claim>
|
var claims = new List<Claim>
|
||||||
{
|
{
|
||||||
new(ClaimTypes.NameIdentifier, uid.Uid),
|
new(ClaimTypes.NameIdentifier, authResult.Uid),
|
||||||
new(ClaimTypes.Authentication, authHeader)
|
new(ClaimTypes.Authentication, authHeader)
|
||||||
};
|
};
|
||||||
|
|
||||||
var identity = new ClaimsIdentity(claims, nameof(SecretKeyGrpcAuthenticationHandler));
|
var identity = new ClaimsIdentity(claims, nameof(SecretKeyAuthenticationHandler));
|
||||||
var principal = new ClaimsPrincipal(identity);
|
var principal = new ClaimsPrincipal(identity);
|
||||||
var ticket = new AuthenticationTicket(principal, Scheme.Name);
|
var ticket = new AuthenticationTicket(principal, Scheme.Name);
|
||||||
|
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
|
using MareSynchronosShared.Data;
|
||||||
|
using MareSynchronosShared.Utils;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace MareSynchronosShared.Authentication;
|
||||||
|
|
||||||
|
public class SecretKeyAuthenticatorService
|
||||||
|
{
|
||||||
|
private readonly IServiceScopeFactory _serviceScopeFactory;
|
||||||
|
private readonly ILogger<SecretKeyAuthenticatorService> _logger;
|
||||||
|
private readonly ConcurrentDictionary<string, SecretKeyAuthReply> _cachedPositiveResponses = new(StringComparer.Ordinal);
|
||||||
|
private readonly ConcurrentDictionary<string, SecretKeyFailedAuthorization?> _failedAuthorizations = new(StringComparer.Ordinal);
|
||||||
|
private readonly int _failedAttemptsForTempBan;
|
||||||
|
private readonly int _tempBanMinutes;
|
||||||
|
private readonly List<string> _whitelistedIps;
|
||||||
|
|
||||||
|
public SecretKeyAuthenticatorService(IServiceScopeFactory serviceScopeFactory, IConfiguration configuration, ILogger<SecretKeyAuthenticatorService> logger)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
var config = configuration.GetRequiredSection("MareSynchronos");
|
||||||
|
_failedAttemptsForTempBan = config.GetValue<int>("FailedAuthForTempBan", 5);
|
||||||
|
logger.LogInformation("FailedAuthForTempBan: {num}", _failedAttemptsForTempBan);
|
||||||
|
_tempBanMinutes = config.GetValue<int>("TempBanDurationInMinutes", 30);
|
||||||
|
logger.LogInformation("TempBanMinutes: {num}", _tempBanMinutes);
|
||||||
|
_whitelistedIps = config.GetSection("WhitelistedIps").Get<List<string>>();
|
||||||
|
foreach (var ip in _whitelistedIps)
|
||||||
|
{
|
||||||
|
logger.LogInformation("Whitelisted IP: " + ip);
|
||||||
|
}
|
||||||
|
_serviceScopeFactory = serviceScopeFactory;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal async Task<SecretKeyAuthReply> AuthorizeAsync(string ip, string secretKey)
|
||||||
|
{
|
||||||
|
if (_cachedPositiveResponses.TryGetValue(secretKey, out var cachedPositiveResponse))
|
||||||
|
{
|
||||||
|
return cachedPositiveResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_failedAuthorizations.TryGetValue(ip, out var existingFailedAuthorization) && existingFailedAuthorization.FailedAttempts > _failedAttemptsForTempBan)
|
||||||
|
{
|
||||||
|
if (existingFailedAuthorization.ResetTask == null)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("TempBan {ip} for authorization spam", ip);
|
||||||
|
|
||||||
|
existingFailedAuthorization.ResetTask = Task.Run(async () =>
|
||||||
|
{
|
||||||
|
await Task.Delay(TimeSpan.FromMinutes(_tempBanMinutes)).ConfigureAwait(false);
|
||||||
|
|
||||||
|
}).ContinueWith((t) =>
|
||||||
|
{
|
||||||
|
_failedAuthorizations.Remove(ip, out _);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return new(Success: false, Uid: null);
|
||||||
|
}
|
||||||
|
|
||||||
|
using var scope = _serviceScopeFactory.CreateScope();
|
||||||
|
using var context = scope.ServiceProvider.GetService<MareDbContext>();
|
||||||
|
var hashedHeader = StringUtils.Sha256String(secretKey);
|
||||||
|
var authReply = await context.Auth.AsNoTracking().SingleOrDefaultAsync(u => u.HashedKey == hashedHeader).ConfigureAwait(false);
|
||||||
|
|
||||||
|
SecretKeyAuthReply reply = new(authReply != null, authReply?.UserUID);
|
||||||
|
|
||||||
|
if (reply.Success)
|
||||||
|
{
|
||||||
|
_cachedPositiveResponses[secretKey] = reply;
|
||||||
|
_ = Task.Run(async () =>
|
||||||
|
{
|
||||||
|
await Task.Delay(TimeSpan.FromMinutes(5)).ConfigureAwait(false);
|
||||||
|
_cachedPositiveResponses.TryRemove(secretKey, out _);
|
||||||
|
});
|
||||||
|
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
return AuthenticationFailure(ip);
|
||||||
|
}
|
||||||
|
|
||||||
|
return reply;
|
||||||
|
}
|
||||||
|
|
||||||
|
private SecretKeyAuthReply AuthenticationFailure(string ip)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Failed authorization from {ip}", ip);
|
||||||
|
if (!_whitelistedIps.Any(w => ip.Contains(w, StringComparison.OrdinalIgnoreCase)))
|
||||||
|
{
|
||||||
|
if (_failedAuthorizations.TryGetValue(ip, out var auth))
|
||||||
|
{
|
||||||
|
auth.IncreaseFailedAttempts();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_failedAuthorizations[ip] = new SecretKeyFailedAuthorization();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new(Success: false, Uid: null);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,6 @@
|
|||||||
using System;
|
namespace MareSynchronosShared.Authentication;
|
||||||
using System.Threading;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
|
|
||||||
namespace MareSynchronosServices.Authentication;
|
internal record SecretKeyFailedAuthorization
|
||||||
|
|
||||||
internal class FailedAuthorization
|
|
||||||
{
|
{
|
||||||
private int failedAttempts = 1;
|
private int failedAttempts = 1;
|
||||||
public int FailedAttempts => failedAttempts;
|
public int FailedAttempts => failedAttempts;
|
||||||
@@ -13,4 +9,4 @@ internal class FailedAuthorization
|
|||||||
{
|
{
|
||||||
Interlocked.Increment(ref failedAttempts);
|
Interlocked.Increment(ref failedAttempts);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4,12 +4,6 @@ option csharp_namespace = "MareSynchronosShared.Protos";
|
|||||||
|
|
||||||
package mareservices;
|
package mareservices;
|
||||||
|
|
||||||
service AuthService {
|
|
||||||
rpc Authorize (stream AuthRequest) returns (stream AuthReply);
|
|
||||||
rpc RemoveAuth (UidMessage) returns (Empty);
|
|
||||||
rpc ClearUnauthorized (Empty) returns (Empty);
|
|
||||||
}
|
|
||||||
|
|
||||||
service FileService {
|
service FileService {
|
||||||
rpc UploadFile (stream UploadFileRequest) returns (Empty);
|
rpc UploadFile (stream UploadFileRequest) returns (Empty);
|
||||||
rpc GetFileSizes (FileSizeRequest) returns (FileSizeResponse);
|
rpc GetFileSizes (FileSizeRequest) returns (FileSizeResponse);
|
||||||
|
|||||||
@@ -1,129 +0,0 @@
|
|||||||
using System.Collections.Concurrent;
|
|
||||||
using MareSynchronosShared.Protos;
|
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
|
|
||||||
namespace MareSynchronosShared.Services;
|
|
||||||
|
|
||||||
public class GrpcAuthenticationService : GrpcBaseService
|
|
||||||
{
|
|
||||||
private record AuthRequestInternal
|
|
||||||
{
|
|
||||||
public AuthRequest Request { get; set; }
|
|
||||||
public long Id { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
private record AuthResponseCache
|
|
||||||
{
|
|
||||||
public AuthReply Response { get; set; }
|
|
||||||
public DateTime WrittenTo { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
private readonly AuthService.AuthServiceClient _authClient;
|
|
||||||
private readonly ConcurrentQueue<AuthRequestInternal> _requestQueue = new();
|
|
||||||
private readonly ConcurrentDictionary<long, AuthReply> _authReplies = new();
|
|
||||||
private readonly ConcurrentDictionary<string, AuthResponseCache> _cachedPositiveResponses = new(StringComparer.Ordinal);
|
|
||||||
private long _requestId = 0;
|
|
||||||
|
|
||||||
public GrpcAuthenticationService(ILogger<GrpcAuthenticationService> logger, AuthService.AuthServiceClient authClient) : base(logger)
|
|
||||||
{
|
|
||||||
_authClient = authClient;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<AuthReply> AuthorizeAsync(string ip, string secretKey)
|
|
||||||
{
|
|
||||||
if (_cachedPositiveResponses.TryGetValue(secretKey, out var cachedPositiveResponse))
|
|
||||||
{
|
|
||||||
if (cachedPositiveResponse.WrittenTo.AddMinutes(5) < DateTime.UtcNow) return cachedPositiveResponse.Response;
|
|
||||||
_cachedPositiveResponses.Remove(secretKey, out _);
|
|
||||||
}
|
|
||||||
|
|
||||||
var id = Interlocked.Increment(ref _requestId);
|
|
||||||
_requestQueue.Enqueue(new AuthRequestInternal()
|
|
||||||
{
|
|
||||||
Id = id,
|
|
||||||
Request = new AuthRequest()
|
|
||||||
{
|
|
||||||
Ip = ip,
|
|
||||||
SecretKey = secretKey,
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
using CancellationTokenSource cts = new(TimeSpan.FromSeconds(30));
|
|
||||||
AuthReply response = null;
|
|
||||||
|
|
||||||
while (!GrpcIsFaulty && !cts.IsCancellationRequested && !_authReplies.TryRemove(id, out response))
|
|
||||||
{
|
|
||||||
await Task.Delay(10, cts.Token).ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (response?.Success ?? false)
|
|
||||||
{
|
|
||||||
_cachedPositiveResponses[secretKey] = new AuthResponseCache
|
|
||||||
{
|
|
||||||
Response = response,
|
|
||||||
WrittenTo = DateTime.UtcNow
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return response ?? new AuthReply
|
|
||||||
{
|
|
||||||
Success = false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task GrpcAuthStream(CancellationToken token)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
using var stream = _authClient.Authorize(cancellationToken: token);
|
|
||||||
while (!token.IsCancellationRequested)
|
|
||||||
{
|
|
||||||
while (_requestQueue.TryDequeue(out var request))
|
|
||||||
{
|
|
||||||
await stream.RequestStream.WriteAsync(request.Request, token).ConfigureAwait(false);
|
|
||||||
await stream.ResponseStream.MoveNext(token).ConfigureAwait(false);
|
|
||||||
_authReplies[request.Id] = stream.ResponseStream.Current;
|
|
||||||
}
|
|
||||||
|
|
||||||
await Task.Delay(10, token).ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
SetGrpcFaulty();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override Task OnGrpcRestore()
|
|
||||||
{
|
|
||||||
return Task.CompletedTask;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override Task PostStartStream()
|
|
||||||
{
|
|
||||||
return Task.CompletedTask;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override Task PreStartStream()
|
|
||||||
{
|
|
||||||
_requestQueue.Clear();
|
|
||||||
_authReplies.Clear();
|
|
||||||
return Task.CompletedTask;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override Task StartAsyncInternal(CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
return Task.CompletedTask;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override Task StartStream(CancellationToken ct)
|
|
||||||
{
|
|
||||||
_ = GrpcAuthStream(ct);
|
|
||||||
return Task.CompletedTask;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override Task StopAsyncInternal(CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
return Task.CompletedTask;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -22,5 +22,9 @@ public class Program
|
|||||||
{
|
{
|
||||||
webBuilder.UseContentRoot(AppContext.BaseDirectory);
|
webBuilder.UseContentRoot(AppContext.BaseDirectory);
|
||||||
webBuilder.UseStartup<Startup>();
|
webBuilder.UseStartup<Startup>();
|
||||||
|
webBuilder.ConfigureKestrel(opt =>
|
||||||
|
{
|
||||||
|
opt.Limits.MaxConcurrentConnections = 5000;
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -2,8 +2,6 @@ using Grpc.Net.Client.Configuration;
|
|||||||
using MareSynchronosShared.Authentication;
|
using MareSynchronosShared.Authentication;
|
||||||
using MareSynchronosShared.Data;
|
using MareSynchronosShared.Data;
|
||||||
using MareSynchronosShared.Metrics;
|
using MareSynchronosShared.Metrics;
|
||||||
using MareSynchronosShared.Protos;
|
|
||||||
using MareSynchronosShared.Services;
|
|
||||||
using Microsoft.AspNetCore.Authentication;
|
using Microsoft.AspNetCore.Authentication;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Builder;
|
using Microsoft.AspNetCore.Builder;
|
||||||
@@ -50,7 +48,6 @@ public class Startup
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
if (!isSecondary)
|
if (!isSecondary)
|
||||||
{
|
{
|
||||||
services.AddSingleton(new MareMetrics(new List<string>
|
services.AddSingleton(new MareMetrics(new List<string>
|
||||||
@@ -63,15 +60,7 @@ public class Startup
|
|||||||
services.AddHostedService<CleanupService>();
|
services.AddHostedService<CleanupService>();
|
||||||
}
|
}
|
||||||
|
|
||||||
services.AddSingleton<GrpcAuthenticationService>();
|
services.AddSingleton<SecretKeyAuthenticatorService>();
|
||||||
services.AddGrpcClient<AuthService.AuthServiceClient>(c =>
|
|
||||||
{
|
|
||||||
c.Address = new Uri(mareSettings.GetValue<string>("ServiceAddress"));
|
|
||||||
}).ConfigureChannel(c =>
|
|
||||||
{
|
|
||||||
c.ServiceConfig = new ServiceConfig { MethodConfigs = { defaultMethodConfig } };
|
|
||||||
});
|
|
||||||
|
|
||||||
services.AddDbContextPool<MareDbContext>(options =>
|
services.AddDbContextPool<MareDbContext>(options =>
|
||||||
{
|
{
|
||||||
options.UseNpgsql(Configuration.GetConnectionString("DefaultConnection"), builder =>
|
options.UseNpgsql(Configuration.GetConnectionString("DefaultConnection"), builder =>
|
||||||
@@ -81,13 +70,11 @@ public class Startup
|
|||||||
options.EnableThreadSafetyChecks(false);
|
options.EnableThreadSafetyChecks(false);
|
||||||
}, mareSettings.GetValue("DbContextPoolSize", 1024));
|
}, mareSettings.GetValue("DbContextPoolSize", 1024));
|
||||||
|
|
||||||
services.AddHostedService(p => p.GetService<GrpcAuthenticationService>());
|
|
||||||
|
|
||||||
services.AddAuthentication(options =>
|
services.AddAuthentication(options =>
|
||||||
{
|
{
|
||||||
options.DefaultScheme = SecretKeyGrpcAuthenticationHandler.AuthScheme;
|
options.DefaultScheme = SecretKeyAuthenticationHandler.AuthScheme;
|
||||||
})
|
})
|
||||||
.AddScheme<AuthenticationSchemeOptions, SecretKeyGrpcAuthenticationHandler>(SecretKeyGrpcAuthenticationHandler.AuthScheme, options => { });
|
.AddScheme<AuthenticationSchemeOptions, SecretKeyAuthenticationHandler>(SecretKeyAuthenticationHandler.AuthScheme, options => { });
|
||||||
services.AddAuthorization(options => options.FallbackPolicy = new AuthorizationPolicyBuilder().RequireAuthenticatedUser().Build());
|
services.AddAuthorization(options => options.FallbackPolicy = new AuthorizationPolicyBuilder().RequireAuthenticatedUser().Build());
|
||||||
|
|
||||||
services.AddGrpc(o =>
|
services.AddGrpc(o =>
|
||||||
@@ -117,7 +104,8 @@ public class Startup
|
|||||||
{
|
{
|
||||||
FileProvider = new PhysicalFileProvider(Configuration.GetRequiredSection("MareSynchronos")["CacheDirectory"]),
|
FileProvider = new PhysicalFileProvider(Configuration.GetRequiredSection("MareSynchronos")["CacheDirectory"]),
|
||||||
RequestPath = "/cache",
|
RequestPath = "/cache",
|
||||||
ServeUnknownFileTypes = true
|
ServeUnknownFileTypes = true,
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!isSecondary)
|
if (!isSecondary)
|
||||||
|
|||||||
Reference in New Issue
Block a user