Merge pull request #22 from LD-Reborn/18-perform-security-scan-on-repo
18 perform security scan on repo
This commit is contained in:
@@ -136,4 +136,9 @@ The script ensures .NET 10 and Ollama are installed, builds the client self-cont
|
|||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
- Request and response bodies are streamed through the tunnel, which is important for Ollama streaming responses.
|
- Request and response bodies are streamed through the tunnel, which is important for Ollama streaming responses.
|
||||||
- Use HTTPS or a private network/VPN when exposing this outside a trusted network. The token is simple shared-secret protection, not a full access-control system.
|
|
||||||
|
## Security notes
|
||||||
|
|
||||||
|
- Use HTTPS or a private network/VPN when exposing this outside a trusted network.
|
||||||
|
- **Tokens in URLs** (`/token/<token>/...` and `?token=...`) are logged by web servers (Apache, Nginx, Kestrel), reverse proxies, and browsers (history). Malicious MITM proxies can also read them. Prefer header-based auth (`X-Reverse-Llama-Token` or `Authorization: Bearer`) when your client supports it.
|
||||||
|
- The token is simple shared-secret protection, not a full access-control system.
|
||||||
|
|||||||
@@ -262,6 +262,13 @@ chmod +x "$INSTALL_DIR/ReverseLlama.Client"
|
|||||||
|
|
||||||
info "Client installed to $INSTALL_DIR."
|
info "Client installed to $INSTALL_DIR."
|
||||||
|
|
||||||
|
# ── Write environment file (avoids shell injection in unit file) ─────────────
|
||||||
|
ENV_DIR="/etc/reversellama-client"
|
||||||
|
mkdir -p "$ENV_DIR"
|
||||||
|
printf 'REVERSE_LLAMA_TOKEN=%s\n' "$TOKEN" > "$ENV_DIR/env"
|
||||||
|
chmod 600 "$ENV_DIR/env"
|
||||||
|
info "Environment file written to $ENV_DIR/env (mode 0600)."
|
||||||
|
|
||||||
# ── Create systemd service ───────────────────────────────────────────────────
|
# ── Create systemd service ───────────────────────────────────────────────────
|
||||||
SERVICE_FILE="/etc/systemd/system/${SERVICE_NAME}.service"
|
SERVICE_FILE="/etc/systemd/system/${SERVICE_NAME}.service"
|
||||||
|
|
||||||
@@ -280,11 +287,12 @@ $([ "$SKIP_OLLAMA" = "false" ] && echo "Wants=ollama.service")
|
|||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
Type=simple
|
Type=simple
|
||||||
ExecStart=$INSTALL_DIR/ReverseLlama.Client --server "$SERVER_URL" --upstream "$UPSTREAM" --token "$TOKEN" --client-id "$CLIENT_ID"
|
ExecStart=$INSTALL_DIR/ReverseLlama.Client --server "$SERVER_URL" --upstream "$UPSTREAM" --client-id "$CLIENT_ID"
|
||||||
Restart=always
|
Restart=always
|
||||||
RestartSec=5
|
RestartSec=5
|
||||||
Environment=DOTNET_CLI_TELEMETRY_OPTOUT=1
|
Environment=DOTNET_CLI_TELEMETRY_OPTOUT=1
|
||||||
Environment=DOTNET_NOLOGO=1
|
Environment=DOTNET_NOLOGO=1
|
||||||
|
EnvironmentFile=$ENV_DIR/env
|
||||||
WorkingDirectory=$INSTALL_DIR
|
WorkingDirectory=$INSTALL_DIR
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
|
|||||||
@@ -41,6 +41,14 @@ internal static class AdminEndpoints
|
|||||||
{
|
{
|
||||||
api.RequireAuthorization();
|
api.RequireAuthorization();
|
||||||
}
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
api.AddEndpointFilter((context, next) =>
|
||||||
|
new ValueTask<object?>(
|
||||||
|
Results.Json(
|
||||||
|
new { error = "No authentication configured - authentication required to use the admin API" },
|
||||||
|
statusCode: StatusCodes.Status403Forbidden)));
|
||||||
|
}
|
||||||
|
|
||||||
api.MapGet("/summary", (HttpContext context, TunnelHub hub, ManagementStore store) =>
|
api.MapGet("/summary", (HttpContext context, TunnelHub hub, ManagementStore store) =>
|
||||||
Results.Json(BuildSummary(context.User, hub, store, settings)));
|
Results.Json(BuildSummary(context.User, hub, store, settings)));
|
||||||
|
|||||||
@@ -0,0 +1,155 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
|
using ElmahCore;
|
||||||
|
|
||||||
|
namespace ReverseLlama.Server;
|
||||||
|
|
||||||
|
internal sealed class AuthRateLimiter
|
||||||
|
{
|
||||||
|
private const int DecayIntervalMinutes = 144; // ~1 step per 2.4 hours
|
||||||
|
|
||||||
|
private readonly ConcurrentDictionary<string, AuthAttemptInfo> _attempts = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
private readonly ILogger<AuthRateLimiter> _logger;
|
||||||
|
private readonly ErrorLog _errorLog;
|
||||||
|
|
||||||
|
public AuthRateLimiter(ILogger<AuthRateLimiter> logger, ErrorLog errorLog)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
_errorLog = errorLog;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RecordFailure(string ipAddress, string endpoint)
|
||||||
|
{
|
||||||
|
var info = _attempts.GetOrAdd(ipAddress, _ => new AuthAttemptInfo());
|
||||||
|
|
||||||
|
lock (info)
|
||||||
|
{
|
||||||
|
info.Count++;
|
||||||
|
info.LastAttemptUtc = DateTime.UtcNow;
|
||||||
|
|
||||||
|
if (info.Count >= 20)
|
||||||
|
{
|
||||||
|
info.BlockedUntilUtc = DateTime.UtcNow.AddHours(48);
|
||||||
|
_logger.LogWarning(
|
||||||
|
"IP {IpAddress} blocked for 48 hours after {Count} failed auth attempts (last: {Endpoint})",
|
||||||
|
ipAddress, info.Count, endpoint);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_logger.LogWarning(
|
||||||
|
"Failed auth attempt #{Count} from {IpAddress} on {Endpoint}",
|
||||||
|
info.Count, ipAddress, endpoint);
|
||||||
|
}
|
||||||
|
|
||||||
|
_errorLog.Log(new Error(new AuthFailureException(ipAddress, endpoint, info.Count)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RecordSuccess(string ipAddress)
|
||||||
|
{
|
||||||
|
if (!_attempts.TryGetValue(ipAddress, out var info))
|
||||||
|
return;
|
||||||
|
|
||||||
|
lock (info)
|
||||||
|
{
|
||||||
|
if (info.Count > 0)
|
||||||
|
{
|
||||||
|
var before = info.Count;
|
||||||
|
info.Count /= 2;
|
||||||
|
info.LastAttemptUtc = DateTime.UtcNow;
|
||||||
|
_logger.LogInformation(
|
||||||
|
"Auth success from {IpAddress}: count reduced from {Before} to {After}",
|
||||||
|
ipAddress, before, info.Count);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public (bool Allowed, TimeSpan? RetryAfter, bool IsBlocked) CheckRateLimit(string ipAddress)
|
||||||
|
{
|
||||||
|
if (!_attempts.TryGetValue(ipAddress, out var info))
|
||||||
|
{
|
||||||
|
return (true, null, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
lock (info)
|
||||||
|
{
|
||||||
|
if (info.BlockedUntilUtc is { } blockedUntil)
|
||||||
|
{
|
||||||
|
if (blockedUntil > DateTime.UtcNow)
|
||||||
|
{
|
||||||
|
return (false, blockedUntil - DateTime.UtcNow, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
info.Count = 0;
|
||||||
|
info.BlockedUntilUtc = null;
|
||||||
|
info.LastAttemptUtc = DateTime.MinValue;
|
||||||
|
return (true, null, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (info.Count > 0)
|
||||||
|
{
|
||||||
|
var elapsed = DateTime.UtcNow - info.LastAttemptUtc;
|
||||||
|
var decayTicks = (int)(elapsed.TotalMinutes / DecayIntervalMinutes);
|
||||||
|
if (decayTicks > 0)
|
||||||
|
{
|
||||||
|
info.Count = Math.Max(0, info.Count - decayTicks);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var waitTime = CalculateWaitTime(info.Count);
|
||||||
|
if (waitTime is { } wait)
|
||||||
|
{
|
||||||
|
var elapsed = DateTime.UtcNow - info.LastAttemptUtc;
|
||||||
|
if (elapsed < wait)
|
||||||
|
{
|
||||||
|
return (false, wait - elapsed, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (true, null, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string GetClientIp(HttpRequest request)
|
||||||
|
{
|
||||||
|
if (request.Headers.TryGetValue("X-Forwarded-For", out var forwardedFor))
|
||||||
|
{
|
||||||
|
var first = forwardedFor.FirstOrDefault();
|
||||||
|
if (!string.IsNullOrWhiteSpace(first))
|
||||||
|
{
|
||||||
|
var commaIndex = first.IndexOf(',');
|
||||||
|
return commaIndex > 0 ? first[..commaIndex].Trim() : first.Trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.Headers.TryGetValue("X-Real-IP", out var realIp))
|
||||||
|
{
|
||||||
|
var first = realIp.FirstOrDefault();
|
||||||
|
if (!string.IsNullOrWhiteSpace(first))
|
||||||
|
{
|
||||||
|
return first.Trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return request.HttpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static TimeSpan? CalculateWaitTime(int attemptCount) =>
|
||||||
|
attemptCount switch
|
||||||
|
{
|
||||||
|
< 3 => null,
|
||||||
|
< 5 => TimeSpan.FromSeconds(5),
|
||||||
|
< 10 => TimeSpan.FromSeconds(5 + (attemptCount - 5) * 5),
|
||||||
|
< 20 => TimeSpan.FromMinutes(attemptCount - 9),
|
||||||
|
_ => null
|
||||||
|
};
|
||||||
|
|
||||||
|
private sealed class AuthAttemptInfo
|
||||||
|
{
|
||||||
|
public int Count;
|
||||||
|
public DateTime LastAttemptUtc;
|
||||||
|
public DateTime? BlockedUntilUtc;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class AuthFailureException(string ipAddress, string endpoint, int attemptCount)
|
||||||
|
: Exception($"Failed auth attempt #{attemptCount} from {ipAddress} on {endpoint}");
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ builder.Services.AddSingleton(settings);
|
|||||||
builder.Services.AddSingleton<TunnelHub>();
|
builder.Services.AddSingleton<TunnelHub>();
|
||||||
builder.Services.AddSingleton<EmbeddingCache>();
|
builder.Services.AddSingleton<EmbeddingCache>();
|
||||||
builder.Services.AddSingleton<ManagementStore>();
|
builder.Services.AddSingleton<ManagementStore>();
|
||||||
|
builder.Services.AddSingleton<AuthRateLimiter>();
|
||||||
builder.Services.AddElmah<ElmahCore.MySql.MySqlErrorLog>().Configure<ElmahOptions>(
|
builder.Services.AddElmah<ElmahCore.MySql.MySqlErrorLog>().Configure<ElmahOptions>(
|
||||||
options => options.ConnectionString = builder.Configuration.GetConnectionString("ElmahConnection"));
|
options => options.ConnectionString = builder.Configuration.GetConnectionString("ElmahConnection"));
|
||||||
|
|
||||||
@@ -30,7 +31,7 @@ if (settings.Keycloak.IsConfigured)
|
|||||||
{
|
{
|
||||||
options.Cookie.Name = "ReverseLlama.Admin";
|
options.Cookie.Name = "ReverseLlama.Admin";
|
||||||
options.Cookie.SameSite = SameSiteMode.Lax;
|
options.Cookie.SameSite = SameSiteMode.Lax;
|
||||||
options.Cookie.SecurePolicy = CookieSecurePolicy.SameAsRequest;
|
options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
|
||||||
options.LoginPath = "/admin/login";
|
options.LoginPath = "/admin/login";
|
||||||
options.LogoutPath = "/admin/logout";
|
options.LogoutPath = "/admin/logout";
|
||||||
})
|
})
|
||||||
@@ -45,9 +46,9 @@ if (settings.Keycloak.IsConfigured)
|
|||||||
options.SaveTokens = true;
|
options.SaveTokens = true;
|
||||||
options.GetClaimsFromUserInfoEndpoint = true;
|
options.GetClaimsFromUserInfoEndpoint = true;
|
||||||
options.CorrelationCookie.SameSite = SameSiteMode.Lax;
|
options.CorrelationCookie.SameSite = SameSiteMode.Lax;
|
||||||
options.CorrelationCookie.SecurePolicy = CookieSecurePolicy.SameAsRequest;
|
options.CorrelationCookie.SecurePolicy = CookieSecurePolicy.Always;
|
||||||
options.NonceCookie.SameSite = SameSiteMode.Lax;
|
options.NonceCookie.SameSite = SameSiteMode.Lax;
|
||||||
options.NonceCookie.SecurePolicy = CookieSecurePolicy.SameAsRequest;
|
options.NonceCookie.SecurePolicy = CookieSecurePolicy.Always;
|
||||||
options.Scope.Clear();
|
options.Scope.Clear();
|
||||||
options.Scope.Add("openid");
|
options.Scope.Add("openid");
|
||||||
options.Scope.Add("profile");
|
options.Scope.Add("profile");
|
||||||
@@ -91,6 +92,8 @@ app.Use(async (context, next) =>
|
|||||||
context.Response.Headers.AccessControlAllowOrigin = "*";
|
context.Response.Headers.AccessControlAllowOrigin = "*";
|
||||||
context.Response.Headers.AccessControlAllowMethods = "GET, POST, PUT, DELETE, PATCH, OPTIONS";
|
context.Response.Headers.AccessControlAllowMethods = "GET, POST, PUT, DELETE, PATCH, OPTIONS";
|
||||||
context.Response.Headers.AccessControlAllowHeaders = "Content-Type, Authorization";
|
context.Response.Headers.AccessControlAllowHeaders = "Content-Type, Authorization";
|
||||||
|
context.Response.Headers.XFrameOptions = "DENY";
|
||||||
|
context.Response.Headers.ContentSecurityPolicy = "frame-ancestors 'none'";
|
||||||
|
|
||||||
if (HttpMethods.IsOptions(context.Request.Method))
|
if (HttpMethods.IsOptions(context.Request.Method))
|
||||||
{
|
{
|
||||||
@@ -101,6 +104,77 @@ app.Use(async (context, next) =>
|
|||||||
await next();
|
await next();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
var rateLimiter = app.Services.GetRequiredService<AuthRateLimiter>();
|
||||||
|
|
||||||
|
app.Use(async (context, next) =>
|
||||||
|
{
|
||||||
|
if (HttpMethods.IsOptions(context.Request.Method))
|
||||||
|
{
|
||||||
|
await next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var ip = AuthRateLimiter.GetClientIp(context.Request);
|
||||||
|
var (allowed, retryAfter, _) = rateLimiter.CheckRateLimit(ip);
|
||||||
|
|
||||||
|
if (!allowed)
|
||||||
|
{
|
||||||
|
context.Response.StatusCode = StatusCodes.Status429TooManyRequests;
|
||||||
|
context.Response.Headers.RetryAfter = ((int)retryAfter!.Value.TotalSeconds).ToString();
|
||||||
|
context.Response.ContentType = "application/json";
|
||||||
|
var seconds = (int)retryAfter!.Value.TotalSeconds;
|
||||||
|
string retryMessage;
|
||||||
|
if (seconds >= 3600)
|
||||||
|
{
|
||||||
|
var hours = seconds / 3600;
|
||||||
|
retryMessage = $"Please try again in {hours} hour{(hours == 1 ? "" : "s")}.";
|
||||||
|
}
|
||||||
|
else if (seconds >= 60)
|
||||||
|
{
|
||||||
|
var minutes = seconds / 60;
|
||||||
|
retryMessage = $"Please try again in {minutes} minute{(minutes == 1 ? "" : "s")}.";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
retryMessage = $"Please try again in {seconds} second{(seconds == 1 ? "" : "s")}.";
|
||||||
|
}
|
||||||
|
await context.Response.WriteAsJsonAsync(new { error = $"Too many requests. {retryMessage}" }, context.RequestAborted);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
context.Response.OnStarting(() =>
|
||||||
|
{
|
||||||
|
if (context.Response.StatusCode is StatusCodes.Status401Unauthorized)
|
||||||
|
{
|
||||||
|
rateLimiter.RecordFailure(ip, context.Request.Path);
|
||||||
|
}
|
||||||
|
else if (context.Response.StatusCode is StatusCodes.Status302Found
|
||||||
|
&& context.Request.Path.StartsWithSegments("/api/admin"))
|
||||||
|
{
|
||||||
|
var location = context.Response.Headers.Location.FirstOrDefault();
|
||||||
|
if (location is not null
|
||||||
|
&& location.Contains("/admin/login", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
rateLimiter.RecordFailure(ip, context.Request.Path);
|
||||||
|
}
|
||||||
|
else if (location is not null
|
||||||
|
&& location.StartsWith("/admin", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
rateLimiter.RecordSuccess(ip);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (context.Response.StatusCode is >= 200 and < 300
|
||||||
|
&& context.Request.Path.StartsWithSegments("/api/admin"))
|
||||||
|
{
|
||||||
|
rateLimiter.RecordSuccess(ip);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Task.CompletedTask;
|
||||||
|
});
|
||||||
|
|
||||||
|
await next();
|
||||||
|
});
|
||||||
|
|
||||||
app.UseWebSockets(new WebSocketOptions
|
app.UseWebSockets(new WebSocketOptions
|
||||||
{
|
{
|
||||||
KeepAliveInterval = TimeSpan.FromSeconds(30)
|
KeepAliveInterval = TimeSpan.FromSeconds(30)
|
||||||
@@ -126,15 +200,11 @@ app.MapGet(settings.StatusPath, (HttpContext context, TunnelHub hub, ServerSetti
|
|||||||
embeddingCache = new
|
embeddingCache = new
|
||||||
{
|
{
|
||||||
available = embeddingCache.IsAvailable,
|
available = embeddingCache.IsAvailable,
|
||||||
count = embeddingCache.Count,
|
count = embeddingCache.Count
|
||||||
databasePath = embeddingCache.DatabasePath,
|
|
||||||
lastError = embeddingCache.LastError
|
|
||||||
},
|
},
|
||||||
management = new
|
management = new
|
||||||
{
|
{
|
||||||
available = managementStore.IsAvailable,
|
available = managementStore.IsAvailable
|
||||||
databasePath = managementStore.DatabasePath,
|
|
||||||
lastError = managementStore.LastError
|
|
||||||
},
|
},
|
||||||
clients = hub.ClientsSnapshot
|
clients = hub.ClientsSnapshot
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
using ElmahCore;
|
||||||
using Microsoft.AspNetCore.Http.Features;
|
using Microsoft.AspNetCore.Http.Features;
|
||||||
using Microsoft.Extensions.Primitives;
|
using Microsoft.Extensions.Primitives;
|
||||||
using ReverseLlama.Protocol;
|
using ReverseLlama.Protocol;
|
||||||
@@ -569,10 +570,16 @@ internal static class ReverseProxyEndpoint
|
|||||||
{
|
{
|
||||||
logger.LogWarning(exception, "Proxy request {RequestId} failed.", requestId);
|
logger.LogWarning(exception, "Proxy request {RequestId} failed.", requestId);
|
||||||
|
|
||||||
|
var errorLog = context.RequestServices.GetService<ErrorLog>();
|
||||||
|
if (errorLog is not null)
|
||||||
|
{
|
||||||
|
await errorLog.LogAsync(new Error(exception, context));
|
||||||
|
}
|
||||||
|
|
||||||
if (!context.Response.HasStarted)
|
if (!context.Response.HasStarted)
|
||||||
{
|
{
|
||||||
context.Response.StatusCode = StatusCodes.Status502BadGateway;
|
context.Response.StatusCode = StatusCodes.Status502BadGateway;
|
||||||
await context.Response.WriteAsync(exception.Message, CancellationToken.None);
|
await context.Response.WriteAsync("Bad gateway", CancellationToken.None);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
using ReverseLlama.Protocol;
|
using ReverseLlama.Protocol;
|
||||||
|
|
||||||
namespace ReverseLlama.Server;
|
namespace ReverseLlama.Server;
|
||||||
@@ -45,6 +47,9 @@ internal static class TokenAuthentication
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Path-token auth: useful for clients that cannot send headers.
|
||||||
|
// SECURITY: the token appears in the URL and will be logged by
|
||||||
|
// web servers, proxies, and browsers. Prefer header auth when possible.
|
||||||
if (allowPathToken
|
if (allowPathToken
|
||||||
&& TryGetPathToken(request.Path, out var pathToken, out _))
|
&& TryGetPathToken(request.Path, out var pathToken, out _))
|
||||||
{
|
{
|
||||||
@@ -55,6 +60,9 @@ internal static class TokenAuthentication
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Query-string auth: needed for clients that cannot send headers
|
||||||
|
// (e.g. browser address bar, status page).
|
||||||
|
// SECURITY: same URL-logging risks as path-token auth above.
|
||||||
if (allowQueryToken
|
if (allowQueryToken
|
||||||
&& request.Query.TryGetValue("token", out var queryValues))
|
&& request.Query.TryGetValue("token", out var queryValues))
|
||||||
{
|
{
|
||||||
@@ -115,7 +123,9 @@ internal static class TokenAuthentication
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(settings.Token)
|
if (!string.IsNullOrWhiteSpace(settings.Token)
|
||||||
&& string.Equals(token, settings.Token, StringComparison.Ordinal))
|
&& CryptographicOperations.FixedTimeEquals(
|
||||||
|
SHA256.HashData(Encoding.UTF8.GetBytes(token)),
|
||||||
|
SHA256.HashData(Encoding.UTF8.GetBytes(settings.Token))))
|
||||||
{
|
{
|
||||||
return AuthResult.Success(null);
|
return AuthResult.Success(null);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user