feat(server): splits user and client into separate key access
This commit is contained in:
@@ -288,6 +288,26 @@ internal static class AdminEndpoints
|
|||||||
? Results.NoContent()
|
? Results.NoContent()
|
||||||
: Results.NotFound(new { error = $"API key '{id}' was not found." }));
|
: Results.NotFound(new { error = $"API key '{id}' was not found." }));
|
||||||
|
|
||||||
|
api.MapGet("/client-keys", (ManagementStore store) =>
|
||||||
|
Results.Json(store.ListClientKeys()));
|
||||||
|
|
||||||
|
api.MapPost("/client-keys", (CreateApiKeyRequest request, ManagementStore store) =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return Results.Json(store.CreateClientKey(request.Name));
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { error = exception.Message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
api.MapDelete("/client-keys/{id}", (string id, ManagementStore store) =>
|
||||||
|
store.DeleteClientKey(id)
|
||||||
|
? Results.NoContent()
|
||||||
|
: Results.NotFound(new { error = $"Client key '{id}' was not found." }));
|
||||||
|
|
||||||
api.MapGet("/groups", (ManagementStore store) =>
|
api.MapGet("/groups", (ManagementStore store) =>
|
||||||
Results.Json(store.ListGroups()));
|
Results.Json(store.ListGroups()));
|
||||||
|
|
||||||
@@ -599,7 +619,9 @@ internal static class AdminEndpoints
|
|||||||
{
|
{
|
||||||
keycloakConfigured = settings.Keycloak.IsConfigured,
|
keycloakConfigured = settings.Keycloak.IsConfigured,
|
||||||
sharedTokenConfigured = !string.IsNullOrWhiteSpace(settings.Token),
|
sharedTokenConfigured = !string.IsNullOrWhiteSpace(settings.Token),
|
||||||
apiKeysConfigured = store.HasApiKeys
|
clientTokenConfigured = !string.IsNullOrWhiteSpace(settings.ClientToken),
|
||||||
|
apiKeysConfigured = store.HasApiKeys,
|
||||||
|
clientKeysConfigured = store.HasClientKeys
|
||||||
},
|
},
|
||||||
management = new
|
management = new
|
||||||
{
|
{
|
||||||
@@ -610,6 +632,7 @@ internal static class AdminEndpoints
|
|||||||
clients = BuildClientSummaries(hub, store),
|
clients = BuildClientSummaries(hub, store),
|
||||||
models = BuildModelSummaries(hub, store),
|
models = BuildModelSummaries(hub, store),
|
||||||
apiKeys = store.ListApiKeys(),
|
apiKeys = store.ListApiKeys(),
|
||||||
|
clientKeys = store.ListClientKeys(),
|
||||||
groups = store.ListGroups(),
|
groups = store.ListGroups(),
|
||||||
apiKeyGroups = store.ListApiKeyGroups(),
|
apiKeyGroups = store.ListApiKeyGroups(),
|
||||||
clientGroups = store.ResolveClientGroups(
|
clientGroups = store.ResolveClientGroups(
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ internal sealed class ManagementStore
|
|||||||
private static readonly TimeSpan ApiKeyLastUsedWriteInterval = TimeSpan.FromMinutes(1);
|
private static readonly TimeSpan ApiKeyLastUsedWriteInterval = TimeSpan.FromMinutes(1);
|
||||||
|
|
||||||
private readonly Dictionary<string, ApiKeyState> _apiKeysByHash = new(StringComparer.Ordinal);
|
private readonly Dictionary<string, ApiKeyState> _apiKeysByHash = new(StringComparer.Ordinal);
|
||||||
|
private readonly Dictionary<string, ApiKeyState> _clientKeysByHash = new(StringComparer.Ordinal);
|
||||||
private readonly string _connectionString = "";
|
private readonly string _connectionString = "";
|
||||||
private readonly string _databasePath = "";
|
private readonly string _databasePath = "";
|
||||||
private readonly object _lock = new();
|
private readonly object _lock = new();
|
||||||
@@ -66,6 +67,22 @@ internal sealed class ManagementStore
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public bool HasClientKeys
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
if (!_isAvailable)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
return _clientKeysByHash.Count > 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public bool IsApiKeyValid(string apiKey, bool updateLastUsed)
|
public bool IsApiKeyValid(string apiKey, bool updateLastUsed)
|
||||||
{
|
{
|
||||||
if (!_isAvailable || string.IsNullOrWhiteSpace(apiKey))
|
if (!_isAvailable || string.IsNullOrWhiteSpace(apiKey))
|
||||||
@@ -200,6 +217,155 @@ internal sealed class ManagementStore
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public bool IsClientKeyValid(string clientKey, bool updateLastUsed)
|
||||||
|
{
|
||||||
|
if (!_isAvailable || string.IsNullOrWhiteSpace(clientKey))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var hash = HashApiKey(clientKey);
|
||||||
|
var now = DateTimeOffset.UtcNow;
|
||||||
|
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
if (!_clientKeysByHash.TryGetValue(hash, out var key))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!updateLastUsed
|
||||||
|
|| key.LastUsedUtc is not null
|
||||||
|
&& now - key.LastUsedUtc.Value < ApiKeyLastUsedWriteInterval)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
key.LastUsedUtc = now;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var connection = OpenConnection();
|
||||||
|
using var command = connection.CreateCommand();
|
||||||
|
command.CommandText = "UPDATE client_keys SET last_used_at_utc = $last_used_at_utc WHERE id = $id";
|
||||||
|
command.Parameters.AddWithValue("$last_used_at_utc", now.ToString("O"));
|
||||||
|
command.Parameters.AddWithValue("$id", key.Id);
|
||||||
|
command.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (exception is SqliteException or IOException or UnauthorizedAccessException)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(exception, "Failed to update client key last-used timestamp.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public IReadOnlyList<ApiKeyInfo> ListClientKeys()
|
||||||
|
{
|
||||||
|
if (!_isAvailable)
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
return _clientKeysByHash.Values
|
||||||
|
.OrderBy(key => key.Name, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ThenBy(key => key.CreatedAtUtc)
|
||||||
|
.Select(key => new ApiKeyInfo(
|
||||||
|
key.Id,
|
||||||
|
key.Name,
|
||||||
|
key.KeyPrefix,
|
||||||
|
key.CreatedAtUtc,
|
||||||
|
key.LastUsedUtc))
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public CreatedApiKey CreateClientKey(string? name)
|
||||||
|
{
|
||||||
|
EnsureAvailable();
|
||||||
|
|
||||||
|
var apiKey = GenerateApiKey();
|
||||||
|
var now = DateTimeOffset.UtcNow;
|
||||||
|
var state = new ApiKeyState
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid().ToString("n"),
|
||||||
|
Name = string.IsNullOrWhiteSpace(name) ? "Client key" : name.Trim(),
|
||||||
|
KeyHash = HashApiKey(apiKey),
|
||||||
|
KeyPrefix = GetKeyPrefix(apiKey),
|
||||||
|
CreatedAtUtc = now
|
||||||
|
};
|
||||||
|
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
using var connection = OpenConnection();
|
||||||
|
using var command = connection.CreateCommand();
|
||||||
|
command.CommandText = """
|
||||||
|
INSERT INTO client_keys (id, name, key_hash, key_prefix, created_at_utc)
|
||||||
|
VALUES ($id, $name, $key_hash, $key_prefix, $created_at_utc)
|
||||||
|
""";
|
||||||
|
command.Parameters.AddWithValue("$id", state.Id);
|
||||||
|
command.Parameters.AddWithValue("$name", state.Name);
|
||||||
|
command.Parameters.AddWithValue("$key_hash", state.KeyHash);
|
||||||
|
command.Parameters.AddWithValue("$key_prefix", state.KeyPrefix);
|
||||||
|
command.Parameters.AddWithValue("$created_at_utc", state.CreatedAtUtc.ToString("O"));
|
||||||
|
command.ExecuteNonQuery();
|
||||||
|
|
||||||
|
_clientKeysByHash[state.KeyHash] = state;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new CreatedApiKey(
|
||||||
|
state.Id,
|
||||||
|
state.Name,
|
||||||
|
state.KeyPrefix,
|
||||||
|
state.CreatedAtUtc,
|
||||||
|
apiKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool DeleteClientKey(string id)
|
||||||
|
{
|
||||||
|
if (!_isAvailable || string.IsNullOrWhiteSpace(id))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
using var connection = OpenConnection();
|
||||||
|
using var command = connection.CreateCommand();
|
||||||
|
command.CommandText = "DELETE FROM client_keys WHERE id = $id";
|
||||||
|
command.Parameters.AddWithValue("$id", id);
|
||||||
|
var deleted = command.ExecuteNonQuery() > 0;
|
||||||
|
|
||||||
|
if (deleted)
|
||||||
|
{
|
||||||
|
foreach (var pair in _clientKeysByHash.Where(pair => pair.Value.Id == id).ToArray())
|
||||||
|
{
|
||||||
|
_clientKeysByHash.Remove(pair.Key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return deleted;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public string? GetClientKeyId(string clientKey)
|
||||||
|
{
|
||||||
|
if (!_isAvailable || string.IsNullOrWhiteSpace(clientKey))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var hash = HashApiKey(clientKey);
|
||||||
|
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
return _clientKeysByHash.TryGetValue(hash, out var key) ? key.Id : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public ClientAccess GetClientAccess(string clientId)
|
public ClientAccess GetClientAccess(string clientId)
|
||||||
{
|
{
|
||||||
if (!_isAvailable || string.IsNullOrWhiteSpace(clientId))
|
if (!_isAvailable || string.IsNullOrWhiteSpace(clientId))
|
||||||
@@ -1825,6 +1991,15 @@ internal sealed class ManagementStore
|
|||||||
group_id TEXT NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
|
group_id TEXT NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
|
||||||
PRIMARY KEY (api_key_id, group_id)
|
PRIMARY KEY (api_key_id, group_id)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS client_keys (
|
||||||
|
id TEXT NOT NULL PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
key_hash TEXT NOT NULL UNIQUE,
|
||||||
|
key_prefix TEXT NOT NULL,
|
||||||
|
created_at_utc TEXT NOT NULL,
|
||||||
|
last_used_at_utc TEXT NULL
|
||||||
|
);
|
||||||
""";
|
""";
|
||||||
command.ExecuteNonQuery();
|
command.ExecuteNonQuery();
|
||||||
|
|
||||||
@@ -1925,6 +2100,35 @@ internal sealed class ManagementStore
|
|||||||
"Loaded {ApiKeyCount} API key(s) from {DatabasePath}.",
|
"Loaded {ApiKeyCount} API key(s) from {DatabasePath}.",
|
||||||
_apiKeysByHash.Count,
|
_apiKeysByHash.Count,
|
||||||
_databasePath);
|
_databasePath);
|
||||||
|
|
||||||
|
using (var command = connection.CreateCommand())
|
||||||
|
{
|
||||||
|
command.CommandText = """
|
||||||
|
SELECT id, name, key_hash, key_prefix, created_at_utc, last_used_at_utc
|
||||||
|
FROM client_keys
|
||||||
|
""";
|
||||||
|
|
||||||
|
using var reader = command.ExecuteReader();
|
||||||
|
while (reader.Read())
|
||||||
|
{
|
||||||
|
var state = new ApiKeyState
|
||||||
|
{
|
||||||
|
Id = reader.GetString(0),
|
||||||
|
Name = reader.GetString(1),
|
||||||
|
KeyHash = reader.GetString(2),
|
||||||
|
KeyPrefix = reader.GetString(3),
|
||||||
|
CreatedAtUtc = ReadDateTimeOffset(reader.GetString(4)),
|
||||||
|
LastUsedUtc = ReadNullableDateTimeOffset(reader, 5)
|
||||||
|
};
|
||||||
|
|
||||||
|
_clientKeysByHash[state.KeyHash] = state;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation(
|
||||||
|
"Loaded {ClientKeyCount} client key(s) from {DatabasePath}.",
|
||||||
|
_clientKeysByHash.Count,
|
||||||
|
_databasePath);
|
||||||
}
|
}
|
||||||
|
|
||||||
private SqliteConnection OpenConnection()
|
private SqliteConnection OpenConnection()
|
||||||
|
|||||||
@@ -296,7 +296,7 @@ app.MapGet(settings.StatusPath, (HttpContext context, TunnelHub hub, ServerSetti
|
|||||||
|
|
||||||
app.Map(settings.TunnelPath, async (HttpContext context, TunnelHub hub, ServerSettings serverSettings, ManagementStore managementStore) =>
|
app.Map(settings.TunnelPath, async (HttpContext context, TunnelHub hub, ServerSettings serverSettings, ManagementStore managementStore) =>
|
||||||
{
|
{
|
||||||
if (!TokenAuthentication.IsAuthorized(context.Request, serverSettings, managementStore, allowQueryToken: true))
|
if (!TokenAuthentication.IsClientAuthorized(context.Request, serverSettings, managementStore, allowQueryToken: true))
|
||||||
{
|
{
|
||||||
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
|
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
|
||||||
await context.Response.WriteAsync($"Missing or invalid {ProtocolConstants.TokenHeader}.", context.RequestAborted);
|
await context.Response.WriteAsync($"Missing or invalid {ProtocolConstants.TokenHeader}.", context.RequestAborted);
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ internal sealed class ServerSettings
|
|||||||
|
|
||||||
public string? Token { get; init; }
|
public string? Token { get; init; }
|
||||||
|
|
||||||
|
public string? ClientToken { get; init; }
|
||||||
|
|
||||||
public int ChunkSize { get; init; } = 64 * 1024;
|
public int ChunkSize { get; init; } = 64 * 1024;
|
||||||
|
|
||||||
public string? EmbeddingCachePath { get; init; }
|
public string? EmbeddingCachePath { get; init; }
|
||||||
@@ -28,6 +30,7 @@ internal sealed class ServerSettings
|
|||||||
StatusPath = NormalizePath(Read(configuration, "ReverseLlama:StatusPath", "status-path") ?? ProtocolConstants.DefaultStatusPath),
|
StatusPath = NormalizePath(Read(configuration, "ReverseLlama:StatusPath", "status-path") ?? ProtocolConstants.DefaultStatusPath),
|
||||||
TunnelPath = NormalizePath(Read(configuration, "ReverseLlama:TunnelPath", "tunnel-path") ?? ProtocolConstants.DefaultTunnelPath),
|
TunnelPath = NormalizePath(Read(configuration, "ReverseLlama:TunnelPath", "tunnel-path") ?? ProtocolConstants.DefaultTunnelPath),
|
||||||
Token = Read(configuration, "ReverseLlama:Token", "token") ?? Environment.GetEnvironmentVariable("REVERSE_LLAMA_TOKEN"),
|
Token = Read(configuration, "ReverseLlama:Token", "token") ?? Environment.GetEnvironmentVariable("REVERSE_LLAMA_TOKEN"),
|
||||||
|
ClientToken = Read(configuration, "ReverseLlama:ClientToken", "client-token") ?? Environment.GetEnvironmentVariable("REVERSE_LLAMA_CLIENT_TOKEN"),
|
||||||
ChunkSize = ReadInt(configuration, 64 * 1024, "ReverseLlama:ChunkSize", "chunk-size", "REVERSE_LLAMA_CHUNK_SIZE"),
|
ChunkSize = ReadInt(configuration, 64 * 1024, "ReverseLlama:ChunkSize", "chunk-size", "REVERSE_LLAMA_CHUNK_SIZE"),
|
||||||
EmbeddingCachePath = Read(
|
EmbeddingCachePath = Read(
|
||||||
configuration,
|
configuration,
|
||||||
|
|||||||
@@ -15,16 +15,11 @@ internal static class TokenAuthentication
|
|||||||
bool allowQueryToken,
|
bool allowQueryToken,
|
||||||
bool allowPathToken = false)
|
bool allowPathToken = false)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(settings.Token) && !managementStore.HasApiKeys)
|
|
||||||
{
|
|
||||||
return AuthResult.Success(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (request.Headers.TryGetValue(ProtocolConstants.TokenHeader, out var headerValues))
|
if (request.Headers.TryGetValue(ProtocolConstants.TokenHeader, out var headerValues))
|
||||||
{
|
{
|
||||||
foreach (var value in headerValues)
|
foreach (var value in headerValues)
|
||||||
{
|
{
|
||||||
var result = AuthorizeToken(value, settings, managementStore, updateApiKeyLastUsed: true);
|
var result = AuthorizeApiToken(value, settings, managementStore, updateApiKeyLastUsed: true);
|
||||||
if (result.IsAuthorized)
|
if (result.IsAuthorized)
|
||||||
{
|
{
|
||||||
return result;
|
return result;
|
||||||
@@ -38,7 +33,7 @@ internal static class TokenAuthentication
|
|||||||
{
|
{
|
||||||
if (TryGetBearerToken(value, out var bearerToken))
|
if (TryGetBearerToken(value, out var bearerToken))
|
||||||
{
|
{
|
||||||
var result = AuthorizeToken(bearerToken, settings, managementStore, updateApiKeyLastUsed: true);
|
var result = AuthorizeApiToken(bearerToken, settings, managementStore, updateApiKeyLastUsed: true);
|
||||||
if (result.IsAuthorized)
|
if (result.IsAuthorized)
|
||||||
{
|
{
|
||||||
return result;
|
return result;
|
||||||
@@ -53,7 +48,7 @@ internal static class TokenAuthentication
|
|||||||
if (allowPathToken
|
if (allowPathToken
|
||||||
&& TryGetPathToken(request.Path, out var pathToken, out _))
|
&& TryGetPathToken(request.Path, out var pathToken, out _))
|
||||||
{
|
{
|
||||||
var result = AuthorizeToken(pathToken, settings, managementStore, updateApiKeyLastUsed: true);
|
var result = AuthorizeApiToken(pathToken, settings, managementStore, updateApiKeyLastUsed: true);
|
||||||
if (result.IsAuthorized)
|
if (result.IsAuthorized)
|
||||||
{
|
{
|
||||||
return result;
|
return result;
|
||||||
@@ -68,7 +63,67 @@ internal static class TokenAuthentication
|
|||||||
{
|
{
|
||||||
foreach (var value in queryValues)
|
foreach (var value in queryValues)
|
||||||
{
|
{
|
||||||
var result = AuthorizeToken(value, settings, managementStore, updateApiKeyLastUsed: true);
|
var result = AuthorizeApiToken(value, settings, managementStore, updateApiKeyLastUsed: true);
|
||||||
|
if (result.IsAuthorized)
|
||||||
|
{
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return AuthResult.Failure;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static AuthResult AuthorizeClient(
|
||||||
|
HttpRequest request,
|
||||||
|
ServerSettings settings,
|
||||||
|
ManagementStore managementStore,
|
||||||
|
bool allowQueryToken,
|
||||||
|
bool allowPathToken = false)
|
||||||
|
{
|
||||||
|
if (request.Headers.TryGetValue(ProtocolConstants.TokenHeader, out var headerValues))
|
||||||
|
{
|
||||||
|
foreach (var value in headerValues)
|
||||||
|
{
|
||||||
|
var result = AuthorizeClientToken(value, settings, managementStore, updateClientKeyLastUsed: true);
|
||||||
|
if (result.IsAuthorized)
|
||||||
|
{
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.Headers.TryGetValue("Authorization", out var authorizationValues))
|
||||||
|
{
|
||||||
|
foreach (var value in authorizationValues)
|
||||||
|
{
|
||||||
|
if (TryGetBearerToken(value, out var bearerToken))
|
||||||
|
{
|
||||||
|
var result = AuthorizeClientToken(bearerToken, settings, managementStore, updateClientKeyLastUsed: true);
|
||||||
|
if (result.IsAuthorized)
|
||||||
|
{
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allowPathToken
|
||||||
|
&& TryGetPathToken(request.Path, out var pathToken, out _))
|
||||||
|
{
|
||||||
|
var result = AuthorizeClientToken(pathToken, settings, managementStore, updateClientKeyLastUsed: true);
|
||||||
|
if (result.IsAuthorized)
|
||||||
|
{
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allowQueryToken
|
||||||
|
&& request.Query.TryGetValue("token", out var queryValues))
|
||||||
|
{
|
||||||
|
foreach (var value in queryValues)
|
||||||
|
{
|
||||||
|
var result = AuthorizeClientToken(value, settings, managementStore, updateClientKeyLastUsed: true);
|
||||||
if (result.IsAuthorized)
|
if (result.IsAuthorized)
|
||||||
{
|
{
|
||||||
return result;
|
return result;
|
||||||
@@ -87,6 +142,14 @@ internal static class TokenAuthentication
|
|||||||
bool allowPathToken = false) =>
|
bool allowPathToken = false) =>
|
||||||
Authorize(request, settings, managementStore, allowQueryToken, allowPathToken).IsAuthorized;
|
Authorize(request, settings, managementStore, allowQueryToken, allowPathToken).IsAuthorized;
|
||||||
|
|
||||||
|
public static bool IsClientAuthorized(
|
||||||
|
HttpRequest request,
|
||||||
|
ServerSettings settings,
|
||||||
|
ManagementStore managementStore,
|
||||||
|
bool allowQueryToken,
|
||||||
|
bool allowPathToken = false) =>
|
||||||
|
AuthorizeClient(request, settings, managementStore, allowQueryToken, allowPathToken).IsAuthorized;
|
||||||
|
|
||||||
public static bool TryRemovePathToken(
|
public static bool TryRemovePathToken(
|
||||||
PathString path,
|
PathString path,
|
||||||
ServerSettings settings,
|
ServerSettings settings,
|
||||||
@@ -96,7 +159,7 @@ internal static class TokenAuthentication
|
|||||||
remainingPath = path;
|
remainingPath = path;
|
||||||
|
|
||||||
if (!TryGetPathToken(path, out var pathToken, out var tokenRemainingPath)
|
if (!TryGetPathToken(path, out var pathToken, out var tokenRemainingPath)
|
||||||
|| !IsTokenAuthorized(pathToken, settings, managementStore, updateApiKeyLastUsed: false))
|
|| !IsApiTokenAuthorized(pathToken, settings, managementStore, updateApiKeyLastUsed: false))
|
||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -109,9 +172,9 @@ internal static class TokenAuthentication
|
|||||||
|
|
||||||
public static bool IsOwnBearerValue(string? value, ServerSettings settings, ManagementStore managementStore) =>
|
public static bool IsOwnBearerValue(string? value, ServerSettings settings, ManagementStore managementStore) =>
|
||||||
TryGetBearerToken(value, out var token)
|
TryGetBearerToken(value, out var token)
|
||||||
&& IsTokenAuthorized(token, settings, managementStore, updateApiKeyLastUsed: false);
|
&& IsApiTokenAuthorized(token, settings, managementStore, updateApiKeyLastUsed: false);
|
||||||
|
|
||||||
private static AuthResult AuthorizeToken(
|
private static AuthResult AuthorizeApiToken(
|
||||||
string? token,
|
string? token,
|
||||||
ServerSettings settings,
|
ServerSettings settings,
|
||||||
ManagementStore managementStore,
|
ManagementStore managementStore,
|
||||||
@@ -140,12 +203,48 @@ internal static class TokenAuthentication
|
|||||||
return AuthResult.Failure;
|
return AuthResult.Failure;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static AuthResult AuthorizeClientToken(
|
||||||
|
string? token,
|
||||||
|
ServerSettings settings,
|
||||||
|
ManagementStore managementStore,
|
||||||
|
bool updateClientKeyLastUsed)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(token))
|
||||||
|
{
|
||||||
|
return AuthResult.Failure;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(settings.ClientToken)
|
||||||
|
&& CryptographicOperations.FixedTimeEquals(
|
||||||
|
SHA256.HashData(Encoding.UTF8.GetBytes(token)),
|
||||||
|
SHA256.HashData(Encoding.UTF8.GetBytes(settings.ClientToken))))
|
||||||
|
{
|
||||||
|
return AuthResult.Success(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
var clientKeyId = managementStore.GetClientKeyId(token);
|
||||||
|
if (clientKeyId is not null)
|
||||||
|
{
|
||||||
|
managementStore.IsClientKeyValid(token, updateClientKeyLastUsed);
|
||||||
|
return AuthResult.Success(clientKeyId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return AuthResult.Failure;
|
||||||
|
}
|
||||||
|
|
||||||
public static bool IsTokenAuthorized(
|
public static bool IsTokenAuthorized(
|
||||||
string? token,
|
string? token,
|
||||||
ServerSettings settings,
|
ServerSettings settings,
|
||||||
ManagementStore managementStore,
|
ManagementStore managementStore,
|
||||||
bool updateApiKeyLastUsed) =>
|
bool updateApiKeyLastUsed) =>
|
||||||
AuthorizeToken(token, settings, managementStore, updateApiKeyLastUsed).IsAuthorized;
|
AuthorizeApiToken(token, settings, managementStore, updateApiKeyLastUsed).IsAuthorized;
|
||||||
|
|
||||||
|
private static bool IsApiTokenAuthorized(
|
||||||
|
string? token,
|
||||||
|
ServerSettings settings,
|
||||||
|
ManagementStore managementStore,
|
||||||
|
bool updateApiKeyLastUsed) =>
|
||||||
|
AuthorizeApiToken(token, settings, managementStore, updateApiKeyLastUsed).IsAuthorized;
|
||||||
|
|
||||||
private static bool TryGetBearerToken(string? authorization, out string token)
|
private static bool TryGetBearerToken(string? authorization, out string token)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ const state = {
|
|||||||
detail: null,
|
detail: null,
|
||||||
groupDetail: null,
|
groupDetail: null,
|
||||||
newKey: null,
|
newKey: null,
|
||||||
|
newClientKey: null,
|
||||||
loading: false,
|
loading: false,
|
||||||
usageData: null
|
usageData: null
|
||||||
};
|
};
|
||||||
@@ -85,6 +86,16 @@ content.addEventListener("click", async (event) => {
|
|||||||
await refresh();
|
await refresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (action === "delete-client-key") {
|
||||||
|
if (!confirm("Delete this client key?")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await api(`/client-keys/${encodeURIComponent(keyId)}`, { method: "DELETE" });
|
||||||
|
setNotice("Client key deleted.");
|
||||||
|
await refresh();
|
||||||
|
}
|
||||||
|
|
||||||
if (action === "copy-key") {
|
if (action === "copy-key") {
|
||||||
await navigator.clipboard.writeText(button.dataset.key);
|
await navigator.clipboard.writeText(button.dataset.key);
|
||||||
setNotice("API key copied.");
|
setNotice("API key copied.");
|
||||||
@@ -174,6 +185,15 @@ content.addEventListener("submit", async (event) => {
|
|||||||
await refresh();
|
await refresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (form.dataset.form === "client-key") {
|
||||||
|
state.newClientKey = await api("/client-keys", {
|
||||||
|
method: "POST",
|
||||||
|
body: { name: data.name }
|
||||||
|
});
|
||||||
|
setNotice("Client key created.");
|
||||||
|
await refresh();
|
||||||
|
}
|
||||||
|
|
||||||
if (form.dataset.form === "create-group") {
|
if (form.dataset.form === "create-group") {
|
||||||
const result = await api("/groups", {
|
const result = await api("/groups", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -359,6 +379,11 @@ async function renderRoute(showLoading = true) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (view === "client-keys") {
|
||||||
|
renderClientKeys();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (view === "groups") {
|
if (view === "groups") {
|
||||||
renderGroups();
|
renderGroups();
|
||||||
return;
|
return;
|
||||||
@@ -382,6 +407,8 @@ function updateShell() {
|
|||||||
<div>${escapeHtml(summary.user?.name || "Signed in")}</div>
|
<div>${escapeHtml(summary.user?.name || "Signed in")}</div>
|
||||||
<div>${summary.clients.length} clients</div>
|
<div>${summary.clients.length} clients</div>
|
||||||
<div>${summary.models.length} models</div>
|
<div>${summary.models.length} models</div>
|
||||||
|
<div>${(summary.clientKeys || []).length} client keys</div>
|
||||||
|
<div>${(summary.apiKeys || []).length} API keys</div>
|
||||||
<div>${(summary.groups || []).length} groups</div>
|
<div>${(summary.groups || []).length} groups</div>
|
||||||
<div>${formatDate(summary.generatedAtUtc)}</div>
|
<div>${formatDate(summary.generatedAtUtc)}</div>
|
||||||
`);
|
`);
|
||||||
@@ -714,6 +741,39 @@ function renderApiKeys() {
|
|||||||
`);
|
`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderClientKeys() {
|
||||||
|
const keys = state.summary?.clientKeys || [];
|
||||||
|
pageTitle.textContent = "Client Keys";
|
||||||
|
pageSubtitle.textContent = "Keys accepted by GPU clients to establish tunnel connections.";
|
||||||
|
|
||||||
|
patchContent(`
|
||||||
|
${state.newClientKey ? newKeyPanel(state.newClientKey) : ""}
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<h2>Create client key</h2>
|
||||||
|
</div>
|
||||||
|
<div class="panel-body">
|
||||||
|
<form class="form-row" data-form="client-key">
|
||||||
|
<div class="field">
|
||||||
|
<label for="clientKeyName">Name</label>
|
||||||
|
<input class="input" id="clientKeyName" name="name" placeholder="e.g. gpu_workstation_1" required>
|
||||||
|
</div>
|
||||||
|
<button class="button" type="submit">Create</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<h2>Client keys</h2>
|
||||||
|
<span class="badge">${keys.length} total</span>
|
||||||
|
</div>
|
||||||
|
<div class="table-wrap">
|
||||||
|
${keys.length ? clientKeysTable(keys) : emptyState("No client keys have been created.")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
function newKeyPanel(key) {
|
function newKeyPanel(key) {
|
||||||
return `
|
return `
|
||||||
<div class="new-key">
|
<div class="new-key">
|
||||||
@@ -756,6 +816,36 @@ function apiKeysTable(keys) {
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function clientKeysTable(keys) {
|
||||||
|
const rows = keys.map((key) => `
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<div class="cell-main">${escapeHtml(key.name)}</div>
|
||||||
|
<div class="cell-sub">${escapeHtml(key.keyPrefix)}...</div>
|
||||||
|
</td>
|
||||||
|
<td>${formatDate(key.createdAtUtc)}</td>
|
||||||
|
<td>${key.lastUsedUtc ? formatDate(key.lastUsedUtc) : "Never"}</td>
|
||||||
|
<td>
|
||||||
|
<button class="button danger" data-action="delete-client-key" data-key-id="${escapeAttr(key.id)}">Delete</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`).join("");
|
||||||
|
|
||||||
|
return `
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Created</th>
|
||||||
|
<th>Last used</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>${rows}</tbody>
|
||||||
|
</table>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
function renderGroups() {
|
function renderGroups() {
|
||||||
const groups = state.summary?.groups || [];
|
const groups = state.summary?.groups || [];
|
||||||
pageTitle.textContent = "Groups";
|
pageTitle.textContent = "Groups";
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
<nav class="nav" aria-label="Admin sections">
|
<nav class="nav" aria-label="Admin sections">
|
||||||
<a href="#clients" data-nav="clients">Clients</a>
|
<a href="#clients" data-nav="clients">Clients</a>
|
||||||
<a href="#models" data-nav="models">Models</a>
|
<a href="#models" data-nav="models">Models</a>
|
||||||
|
<a href="#client-keys" data-nav="client-keys">Client keys</a>
|
||||||
<a href="#api-keys" data-nav="api-keys">API keys</a>
|
<a href="#api-keys" data-nav="api-keys">API keys</a>
|
||||||
<a href="#groups" data-nav="groups">Groups</a>
|
<a href="#groups" data-nav="groups">Groups</a>
|
||||||
<a href="#usage" data-nav="usage">Usage</a>
|
<a href="#usage" data-nav="usage">Usage</a>
|
||||||
|
|||||||
Reference in New Issue
Block a user