Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions src/AprsMap.Client.Remote/RemoteEngineService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -566,6 +566,15 @@ public Task<ApiKeyStatus> SaveApiKeyAsync(string key, CancellationToken cancella
);
}

/// <inheritdoc />
public Task<ApiKeyCheck> CheckApiKeyAsync(string key, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(key);
return WithEngineErrors(() =>
_connection.InvokeAsync<ApiKeyCheck>(EngineHub.CheckApiKey, key, cancellationToken)
);
}

/// <inheritdoc />
public Task RemoveApiKeyAsync(CancellationToken cancellationToken = default) =>
WithEngineErrors(() => _connection.InvokeAsync(EngineHub.RemoveApiKey, cancellationToken));
Expand Down
3 changes: 3 additions & 0 deletions src/AprsMap.Contracts/EngineHub.cs
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,9 @@ public static class EngineHub
/// <summary>Hub method for <see cref="IEngineService.SaveApiKeyAsync"/>.</summary>
public const string SaveApiKey = "SaveApiKey";

/// <summary>Hub method for <see cref="IEngineService.CheckApiKeyAsync"/>.</summary>
public const string CheckApiKey = "CheckApiKey";

/// <summary>Hub method for <see cref="IEngineService.RemoveApiKeyAsync"/>.</summary>
public const string RemoveApiKey = "RemoveApiKey";

Expand Down
8 changes: 8 additions & 0 deletions src/AprsMap.Contracts/IEngineService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -504,6 +504,14 @@ Task<DestinationInfo> GetDestinationAsync(
/// <exception cref="UnauthorizedAccessException">As for <see cref="GetApiKeyStatusAsync"/>.</exception>
Task<ApiKeyStatus> SaveApiKeyAsync(string key, CancellationToken cancellationToken = default);

/// <summary>
/// Whether <paramref name="key"/> is the gateway's current API key (#447), saving nothing:
/// <see cref="ApiKeyCheck.Accepted"/> when it is, <see cref="ApiKeyCheck.Refused"/> when it isn't, and
/// <see cref="ApiKeyCheck.None"/> when no key is saved. The operator's only.
/// </summary>
/// <exception cref="UnauthorizedAccessException">As for <see cref="GetApiKeyStatusAsync"/>.</exception>
Task<ApiKeyCheck> CheckApiKeyAsync(string key, CancellationToken cancellationToken = default);

/// <summary>Removes the API key: API access is off (behavior 1).</summary>
/// <exception cref="UnauthorizedAccessException">As for <see cref="GetApiKeyStatusAsync"/>.</exception>
Task RemoveApiKeyAsync(CancellationToken cancellationToken = default);
Expand Down
4 changes: 4 additions & 0 deletions src/AprsMap.Core/Engine.ApiKey.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ public Task<string> GenerateApiKeyAsync(CancellationToken cancellationToken = de
public Task<ApiKeyStatus> SaveApiKeyAsync(string key, CancellationToken cancellationToken = default) =>
Task.FromException<ApiKeyStatus>(NotHere());

/// <inheritdoc />
public Task<ApiKeyCheck> CheckApiKeyAsync(string key, CancellationToken cancellationToken = default) =>
Task.FromException<ApiKeyCheck>(NotHere());

/// <inheritdoc />
public Task RemoveApiKeyAsync(CancellationToken cancellationToken = default) => Task.FromException(NotHere());

Expand Down
11 changes: 11 additions & 0 deletions src/AprsMap.Server/EngineHubEndpoint.cs
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,17 @@ public ApiKeyStatus SaveApiKey(string key)
}
}

/// <summary>Whether a key is the current one (#447), for the operator checking a key an app has; saves nothing.</summary>
[HubMethodName(EngineHub.CheckApiKey)]
public ApiKeyCheck CheckApiKey(string key)
{
RequireOperator();
ArgumentNullException.ThrowIfNull(key);
return !apiKeys.Status.Set ? ApiKeyCheck.None
: apiKeys.Check(key.Trim()) is not null ? ApiKeyCheck.Accepted
: ApiKeyCheck.Refused;
}

[HubMethodName(EngineHub.RemoveApiKey)]
public void RemoveApiKey()
{
Expand Down
12 changes: 12 additions & 0 deletions src/AprsMap.UI/Localization/Strings.resx
Original file line number Diff line number Diff line change
Expand Up @@ -2598,6 +2598,18 @@ In a Docker container:
<data name="GatewayAccess.On" xml:space="preserve">
<value>Let apps in with an API key</value>
</data>
<data name="GatewayAccess.Test" xml:space="preserve">
<value>Test</value>
</data>
<data name="GatewayAccess.Test.Accepted" xml:space="preserve">
<value>That's the gateway's current key.</value>
</data>
<data name="GatewayAccess.Test.Refused" xml:space="preserve">
<value>That isn't the gateway's current key. Apps using it can only watch.</value>
</data>
<data name="GatewayAccess.Test.None" xml:space="preserve">
<value>No key is saved, so apps can only watch.</value>
</data>
<data name="GatewayAccess.Saved" xml:space="preserve">
<value>Saved. Apps using the old key are disconnected and can only watch until they have this one.</value>
</data>
Expand Down
21 changes: 20 additions & 1 deletion src/AprsMap.UI/Settings/GatewayAccessSectionViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ internal GatewayAccessSectionViewModel(IEngineService engine, Localizer text, Ti

/// <summary>The key being set: generated, typed or pasted; empty once it's saved.</summary>
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(ApplyCommand), nameof(CopyCommand))]
[NotifyCanExecuteChangedFor(nameof(ApplyCommand), nameof(CopyCommand), nameof(TestCommand))]
public partial string Key { get; set; } = string.Empty;

/// <summary>Apps may connect with an API key; turned off and applied, the saved key is removed.</summary>
Expand Down Expand Up @@ -112,6 +112,25 @@ private async Task GenerateAsync()

private bool HasKey => Key.Length > 0;

/// <summary>
/// Tests the key in the field against the gateway's current one, saving nothing (#447): the way to check a key an app
/// or a password manager has, since the gateway keeps only its hash.
/// </summary>
[RelayCommand(CanExecute = nameof(HasKey))]
private async Task TestAsync()
{
try
{
var check = await _engine.CheckApiKeyAsync(Key.Trim());
HasProblem = check != ApiKeyCheck.Accepted;
Message = _text[$"GatewayAccess.Test.{check}"];
}
catch (Exception ex) when (ex is UnauthorizedAccessException or InvalidOperationException)
{
Problem(ex);
}
}

/// <summary>Copies the key in the field: the only time it can be seen (behavior 6).</summary>
[RelayCommand(CanExecute = nameof(HasKey))]
private Task CopyAsync() => CopyToClipboard?.Invoke(Key) ?? Task.CompletedTask;
Expand Down
19 changes: 13 additions & 6 deletions src/AprsMap.UI/Views/SettingsView.axaml
Original file line number Diff line number Diff line change
Expand Up @@ -2011,12 +2011,19 @@
/>
</WrapPanel>
</StackPanel>
<Button
x:Name="GatewayAccessApply"
Content="{l:T WebViewer.Apply}"
Command="{Binding GatewayAccess.ApplyCommand, FallbackValue={x:Null}}"
HorizontalAlignment="Left"
/>
<!-- Test (#447): always there, to check a key an app has against the current one; Apply saves. -->
<WrapPanel ItemSpacing="8">
<Button
x:Name="GatewayAccessTest"
Content="{l:T GatewayAccess.Test}"
Command="{Binding GatewayAccess.TestCommand, FallbackValue={x:Null}}"
/>
<Button
x:Name="GatewayAccessApply"
Content="{l:T WebViewer.Apply}"
Command="{Binding GatewayAccess.ApplyCommand, FallbackValue={x:Null}}"
/>
</WrapPanel>
<TextBlock
x:Name="GatewayAccessMessage"
Text="{Binding GatewayAccess.Message, FallbackValue={x:Null}}"
Expand Down
19 changes: 19 additions & 0 deletions tests/AprsMap.Gateway.Tests/ApiKeyTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,25 @@ public async Task AGeneratedKeyIs43UrlSafeCharactersAndOnlyItsHashIsKept()
Assert.DoesNotContain(gateway.Logs.Messages, m => m.Contains(key, StringComparison.Ordinal));
}

[Fact]
public async Task TheOperatorCanTestAKeyAgainstTheCurrentOneWithoutSavingIt()
{
// #447
await using var op = await OperatorAsync();
await op.RemoveApiKeyAsync(Ct);
Assert.Equal(ApiKeyCheck.None, await op.CheckApiKeyAsync(ApiKeyStore.Generate(), Ct));

var key = await SaveNewKeyAsync();

Assert.Equal(ApiKeyCheck.Accepted, await op.CheckApiKeyAsync(key, Ct));
Assert.Equal(ApiKeyCheck.Accepted, await op.CheckApiKeyAsync($" {key} ", Ct)); // as pasted
Assert.Equal(ApiKeyCheck.Refused, await op.CheckApiKeyAsync(ApiKeyStore.Generate(), Ct));
Assert.True((await op.GetApiKeyStatusAsync(Ct)).Set); // nothing changed
await using var viewer = await AppAsync(null);
var refused = await Assert.ThrowsAsync<EngineAccessException>(() => viewer.CheckApiKeyAsync(key, Ct));
Assert.Equal(EngineErrorCodes.OperatorOnly, refused.EngineError.Code);
}

[Fact]
public async Task AShortKeyIsRefused()
{
Expand Down
10 changes: 10 additions & 0 deletions tests/AprsMap.UI.Tests/FakeEngine.cs
Original file line number Diff line number Diff line change
Expand Up @@ -925,6 +925,16 @@ public Task<ApiKeyStatus> SaveApiKeyAsync(string key, CancellationToken cancella
return Task.FromResult(ApiKey);
}

/// <summary>The key <see cref="CheckApiKeyAsync"/> takes for the current one.</summary>
public string? CurrentApiKey { get; set; }

public Task<ApiKeyCheck> CheckApiKeyAsync(string key, CancellationToken cancellationToken = default) =>
Task.FromResult(
!ApiKey.Set ? ApiKeyCheck.None
: key == CurrentApiKey ? ApiKeyCheck.Accepted
: ApiKeyCheck.Refused
);

public Task RemoveApiKeyAsync(CancellationToken cancellationToken = default)
{
ApiKey = ApiKeyStatus.None;
Expand Down
25 changes: 25 additions & 0 deletions tests/AprsMap.UI.Tests/GatewayAccessUiTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,31 @@ public async Task AShortKeySaysWhyAndApplyingOffRemovesTheKey()
Assert.Equal("Removed. Apps can only watch.", section.Message);
}

[Fact]
public async Task TestSaysWhetherAKeyIsTheCurrentOneAndSavesNothing()
{
// #447
var engine = new FakeEngine
{
ApiKey = new ApiKeyStatus(true, DateTimeOffset.UnixEpoch),
CurrentApiKey = new string('k', 43),
};
var section = await Section(engine);
Assert.False(section.TestCommand.CanExecute(null)); // nothing to test yet

section.Key = new string('k', 43);
await section.TestCommand.ExecuteAsync(null);
Assert.Equal(("That's the gateway's current key.", false), (section.Message, section.HasProblem));

section.Key = new string('x', 43);
await section.TestCommand.ExecuteAsync(null);
Assert.Equal(
("That isn't the gateway's current key. Apps using it can only watch.", true),
(section.Message, section.HasProblem)
);
Assert.Empty(engine.SavedApiKeys);
}

[Fact]
public async Task OnlyASignedInGatewayOperatorHasTheSection()
{
Expand Down
Loading