ec4c8fab87
Move configuration options from Core/DataAccess/DataSync/ExcelIO to dedicated Options folders within each project for better organization. Update all references and tests accordingly.
1385 lines
43 KiB
Markdown
1385 lines
43 KiB
Markdown
# Encrypted Login Implementation Plan
|
|
|
|
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
|
|
|
**Goal:** Consolidate login models into Core and implement RSA-OAEP encryption for login credentials between Blazor WASM client and API.
|
|
|
|
**Architecture:** Client fetches server's RSA public key at runtime, encrypts login credentials with RSA-OAEP, sends encrypted payload to API. Server decrypts with private key (auto-generated on first startup, persisted to file). Shared models in Core eliminate duplication.
|
|
|
|
**Tech Stack:** .NET 10, RSA-OAEP (SHA-256), Blazor.SubtleCrypto (Web Crypto API wrapper), xUnit, NSubstitute, Shouldly
|
|
|
|
---
|
|
|
|
## Task 1: Add Shared Auth Models to Core
|
|
|
|
**Files:**
|
|
- Create: `NEW/src/JdeScoping.Core/Models/Auth/LoginModel.cs`
|
|
- Create: `NEW/src/JdeScoping.Core/Models/Auth/LoginResultModel.cs`
|
|
- Create: `NEW/src/JdeScoping.Core/Models/Auth/EncryptedLoginRequest.cs`
|
|
- Create: `NEW/src/JdeScoping.Core/Models/Auth/PublicKeyResponse.cs`
|
|
|
|
**Step 1: Create Auth folder and LoginModel**
|
|
|
|
```csharp
|
|
// NEW/src/JdeScoping.Core/Models/Auth/LoginModel.cs
|
|
using System.ComponentModel.DataAnnotations;
|
|
|
|
namespace JdeScoping.Core.Models.Auth;
|
|
|
|
/// <summary>
|
|
/// Login credentials model shared by Client and API.
|
|
/// </summary>
|
|
public class LoginModel
|
|
{
|
|
[Required(ErrorMessage = "Username is required")]
|
|
public string Username { get; set; } = string.Empty;
|
|
|
|
[Required(ErrorMessage = "Password is required")]
|
|
public string Password { get; set; } = string.Empty;
|
|
}
|
|
```
|
|
|
|
**Step 2: Create LoginResultModel**
|
|
|
|
```csharp
|
|
// NEW/src/JdeScoping.Core/Models/Auth/LoginResultModel.cs
|
|
namespace JdeScoping.Core.Models.Auth;
|
|
|
|
/// <summary>
|
|
/// Result returned from login API endpoint.
|
|
/// </summary>
|
|
/// <param name="Success">Whether authentication succeeded</param>
|
|
/// <param name="ErrorMessage">Error message if failed</param>
|
|
/// <param name="User">User info if successful</param>
|
|
public record LoginResultModel(
|
|
bool Success,
|
|
string? ErrorMessage,
|
|
UserInfo? User);
|
|
```
|
|
|
|
**Step 3: Create EncryptedLoginRequest**
|
|
|
|
```csharp
|
|
// NEW/src/JdeScoping.Core/Models/Auth/EncryptedLoginRequest.cs
|
|
namespace JdeScoping.Core.Models.Auth;
|
|
|
|
/// <summary>
|
|
/// Encrypted login payload sent from client to API.
|
|
/// </summary>
|
|
/// <param name="EncryptedData">Base64-encoded RSA-encrypted JSON of LoginModel</param>
|
|
public record EncryptedLoginRequest(string EncryptedData);
|
|
```
|
|
|
|
**Step 4: Create PublicKeyResponse**
|
|
|
|
```csharp
|
|
// NEW/src/JdeScoping.Core/Models/Auth/PublicKeyResponse.cs
|
|
namespace JdeScoping.Core.Models.Auth;
|
|
|
|
/// <summary>
|
|
/// Server's RSA public key for client-side encryption.
|
|
/// </summary>
|
|
/// <param name="PublicKeyPem">PEM-encoded RSA public key</param>
|
|
public record PublicKeyResponse(string PublicKeyPem);
|
|
```
|
|
|
|
**Step 5: Verify build succeeds**
|
|
|
|
Run: `dotnet build NEW/src/JdeScoping.Core/JdeScoping.Core.csproj`
|
|
Expected: Build succeeded
|
|
|
|
**Step 6: Commit**
|
|
|
|
```bash
|
|
git add NEW/src/JdeScoping.Core/Models/Auth/
|
|
git commit -m "feat(core): add shared auth models for encrypted login"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 2: Add IRsaKeyService Interface to Core
|
|
|
|
**Files:**
|
|
- Create: `NEW/src/JdeScoping.Core/Interfaces/IRsaKeyService.cs`
|
|
|
|
**Step 1: Create the interface**
|
|
|
|
```csharp
|
|
// NEW/src/JdeScoping.Core/Interfaces/IRsaKeyService.cs
|
|
namespace JdeScoping.Core.Interfaces;
|
|
|
|
/// <summary>
|
|
/// RSA key management for login encryption.
|
|
/// </summary>
|
|
public interface IRsaKeyService
|
|
{
|
|
/// <summary>
|
|
/// Gets the server's public key in PEM format.
|
|
/// </summary>
|
|
string GetPublicKeyPem();
|
|
|
|
/// <summary>
|
|
/// Decrypts RSA-OAEP encrypted data.
|
|
/// </summary>
|
|
/// <param name="ciphertext">Encrypted bytes</param>
|
|
/// <returns>Decrypted plaintext bytes</returns>
|
|
byte[] Decrypt(byte[] ciphertext);
|
|
}
|
|
```
|
|
|
|
**Step 2: Verify build succeeds**
|
|
|
|
Run: `dotnet build NEW/src/JdeScoping.Core/JdeScoping.Core.csproj`
|
|
Expected: Build succeeded
|
|
|
|
**Step 3: Commit**
|
|
|
|
```bash
|
|
git add NEW/src/JdeScoping.Core/Interfaces/IRsaKeyService.cs
|
|
git commit -m "feat(core): add IRsaKeyService interface"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 3: Implement RsaKeyService with Tests (TDD)
|
|
|
|
**Files:**
|
|
- Create: `NEW/tests/JdeScoping.Infrastructure.Tests/Security/RsaKeyServiceTests.cs`
|
|
- Create: `NEW/src/JdeScoping.Infrastructure/Security/RsaKeyService.cs`
|
|
|
|
**Step 1: Write failing test for GetPublicKeyPem**
|
|
|
|
```csharp
|
|
// NEW/tests/JdeScoping.Infrastructure.Tests/Security/RsaKeyServiceTests.cs
|
|
using JdeScoping.Core.Interfaces;
|
|
using JdeScoping.Infrastructure.Security;
|
|
using Shouldly;
|
|
|
|
namespace JdeScoping.Infrastructure.Tests.Security;
|
|
|
|
public class RsaKeyServiceTests : IDisposable
|
|
{
|
|
private readonly string _testKeyPath;
|
|
|
|
public RsaKeyServiceTests()
|
|
{
|
|
_testKeyPath = Path.Combine(Path.GetTempPath(), $"test-rsa-key-{Guid.NewGuid()}.bin");
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
if (File.Exists(_testKeyPath))
|
|
File.Delete(_testKeyPath);
|
|
}
|
|
|
|
[Fact]
|
|
public void GetPublicKeyPem_ReturnsValidPemFormat()
|
|
{
|
|
// Arrange
|
|
var service = new RsaKeyService(_testKeyPath);
|
|
|
|
// Act
|
|
var pem = service.GetPublicKeyPem();
|
|
|
|
// Assert
|
|
pem.ShouldStartWith("-----BEGIN PUBLIC KEY-----");
|
|
pem.ShouldEndWith("-----END PUBLIC KEY-----");
|
|
}
|
|
|
|
[Fact]
|
|
public void Constructor_WhenKeyFileMissing_GeneratesAndPersistsKey()
|
|
{
|
|
// Arrange - ensure file doesn't exist
|
|
File.Exists(_testKeyPath).ShouldBeFalse();
|
|
|
|
// Act
|
|
_ = new RsaKeyService(_testKeyPath);
|
|
|
|
// Assert
|
|
File.Exists(_testKeyPath).ShouldBeTrue();
|
|
new FileInfo(_testKeyPath).Length.ShouldBeGreaterThan(0);
|
|
}
|
|
|
|
[Fact]
|
|
public void Constructor_WhenKeyFileExists_LoadsSameKey()
|
|
{
|
|
// Arrange - create service to generate key
|
|
var service1 = new RsaKeyService(_testKeyPath);
|
|
var publicKey1 = service1.GetPublicKeyPem();
|
|
|
|
// Act - create new service instance
|
|
var service2 = new RsaKeyService(_testKeyPath);
|
|
var publicKey2 = service2.GetPublicKeyPem();
|
|
|
|
// Assert - same key loaded
|
|
publicKey2.ShouldBe(publicKey1);
|
|
}
|
|
|
|
[Fact]
|
|
public void Decrypt_WithValidCiphertext_ReturnsPlaintext()
|
|
{
|
|
// Arrange
|
|
var service = new RsaKeyService(_testKeyPath);
|
|
var plaintext = "Hello, World!"u8.ToArray();
|
|
|
|
// Encrypt using public key (simulating what client does)
|
|
using var rsa = System.Security.Cryptography.RSA.Create();
|
|
rsa.ImportFromPem(service.GetPublicKeyPem());
|
|
var ciphertext = rsa.Encrypt(plaintext, System.Security.Cryptography.RSAEncryptionPadding.OaepSHA256);
|
|
|
|
// Act
|
|
var decrypted = service.Decrypt(ciphertext);
|
|
|
|
// Assert
|
|
decrypted.ShouldBe(plaintext);
|
|
}
|
|
|
|
[Fact]
|
|
public void Decrypt_WithInvalidCiphertext_ThrowsCryptographicException()
|
|
{
|
|
// Arrange
|
|
var service = new RsaKeyService(_testKeyPath);
|
|
var invalidCiphertext = new byte[] { 1, 2, 3, 4, 5 };
|
|
|
|
// Act & Assert
|
|
Should.Throw<System.Security.Cryptography.CryptographicException>(
|
|
() => service.Decrypt(invalidCiphertext));
|
|
}
|
|
}
|
|
```
|
|
|
|
**Step 2: Run tests to verify they fail**
|
|
|
|
Run: `dotnet test NEW/tests/JdeScoping.Infrastructure.Tests --filter "FullyQualifiedName~RsaKeyServiceTests" --verbosity normal`
|
|
Expected: FAIL - RsaKeyService class not found
|
|
|
|
**Step 3: Implement RsaKeyService**
|
|
|
|
```csharp
|
|
// NEW/src/JdeScoping.Infrastructure/Security/RsaKeyService.cs
|
|
using System.Security.Cryptography;
|
|
using JdeScoping.Core.Interfaces;
|
|
|
|
namespace JdeScoping.Infrastructure.Security;
|
|
|
|
/// <summary>
|
|
/// RSA key service that auto-generates and persists keys.
|
|
/// </summary>
|
|
public class RsaKeyService : IRsaKeyService, IDisposable
|
|
{
|
|
private readonly RSA _rsa;
|
|
|
|
/// <summary>
|
|
/// Creates a new RSA key service.
|
|
/// </summary>
|
|
/// <param name="keyFilePath">Path to persist the private key</param>
|
|
public RsaKeyService(string keyFilePath)
|
|
{
|
|
_rsa = RSA.Create(2048);
|
|
|
|
if (File.Exists(keyFilePath))
|
|
{
|
|
var keyBytes = File.ReadAllBytes(keyFilePath);
|
|
_rsa.ImportRSAPrivateKey(keyBytes, out _);
|
|
}
|
|
else
|
|
{
|
|
var privateKey = _rsa.ExportRSAPrivateKey();
|
|
var directory = Path.GetDirectoryName(keyFilePath);
|
|
if (!string.IsNullOrEmpty(directory))
|
|
Directory.CreateDirectory(directory);
|
|
File.WriteAllBytes(keyFilePath, privateKey);
|
|
}
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public string GetPublicKeyPem()
|
|
=> _rsa.ExportSubjectPublicKeyInfoPem();
|
|
|
|
/// <inheritdoc />
|
|
public byte[] Decrypt(byte[] ciphertext)
|
|
=> _rsa.Decrypt(ciphertext, RSAEncryptionPadding.OaepSHA256);
|
|
|
|
public void Dispose()
|
|
{
|
|
_rsa.Dispose();
|
|
GC.SuppressFinalize(this);
|
|
}
|
|
}
|
|
```
|
|
|
|
**Step 4: Run tests to verify they pass**
|
|
|
|
Run: `dotnet test NEW/tests/JdeScoping.Infrastructure.Tests --filter "FullyQualifiedName~RsaKeyServiceTests" --verbosity normal`
|
|
Expected: All 5 tests pass
|
|
|
|
**Step 5: Commit**
|
|
|
|
```bash
|
|
git add NEW/src/JdeScoping.Infrastructure/Security/RsaKeyService.cs
|
|
git add NEW/tests/JdeScoping.Infrastructure.Tests/Security/RsaKeyServiceTests.cs
|
|
git commit -m "feat(infrastructure): implement RsaKeyService with tests"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 4: Add RsaKeyOptions and DI Registration
|
|
|
|
**Files:**
|
|
- Create: `NEW/src/JdeScoping.Core/Options/RsaKeyOptions.cs`
|
|
- Modify: `NEW/src/JdeScoping.Infrastructure/DependencyInjection.cs`
|
|
|
|
**Step 1: Create RsaKeyOptions**
|
|
|
|
```csharp
|
|
// NEW/src/JdeScoping.Core/Options/RsaKeyOptions.cs
|
|
namespace JdeScoping.Core.Options;
|
|
|
|
/// <summary>
|
|
/// Configuration options for RSA key service.
|
|
/// </summary>
|
|
public class RsaKeyOptions
|
|
{
|
|
public const string SectionName = "RsaKey";
|
|
|
|
/// <summary>
|
|
/// Path to store the RSA private key file.
|
|
/// Defaults to "data/rsa-key.bin" relative to app directory.
|
|
/// </summary>
|
|
public string KeyFilePath { get; set; } = "data/rsa-key.bin";
|
|
}
|
|
```
|
|
|
|
**Step 2: Update Infrastructure DependencyInjection**
|
|
|
|
Add to `NEW/src/JdeScoping.Infrastructure/DependencyInjection.cs` after existing using statements:
|
|
|
|
```csharp
|
|
using JdeScoping.Infrastructure.Security;
|
|
```
|
|
|
|
Add inside `AddInfrastructure` method, after the auth service registration (around line 60):
|
|
|
|
```csharp
|
|
// Register RSA key service for login encryption
|
|
services.Configure<RsaKeyOptions>(
|
|
configuration.GetSection(RsaKeyOptions.SectionName));
|
|
|
|
var rsaKeyOptions = configuration
|
|
.GetSection(RsaKeyOptions.SectionName)
|
|
.Get<RsaKeyOptions>() ?? new RsaKeyOptions();
|
|
|
|
var keyPath = Path.IsPathRooted(rsaKeyOptions.KeyFilePath)
|
|
? rsaKeyOptions.KeyFilePath
|
|
: Path.Combine(AppContext.BaseDirectory, rsaKeyOptions.KeyFilePath);
|
|
|
|
services.AddSingleton<IRsaKeyService>(new RsaKeyService(keyPath));
|
|
```
|
|
|
|
**Step 3: Verify build succeeds**
|
|
|
|
Run: `dotnet build NEW/src/JdeScoping.Infrastructure/JdeScoping.Infrastructure.csproj`
|
|
Expected: Build succeeded
|
|
|
|
**Step 4: Commit**
|
|
|
|
```bash
|
|
git add NEW/src/JdeScoping.Core/Options/RsaKeyOptions.cs
|
|
git add NEW/src/JdeScoping.Infrastructure/DependencyInjection.cs
|
|
git commit -m "feat(infrastructure): register RsaKeyService in DI"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 5: Update AuthController to Use Encrypted Login
|
|
|
|
**Files:**
|
|
- Modify: `NEW/src/JdeScoping.Api/Controllers/AuthController.cs`
|
|
- Delete: `NEW/src/JdeScoping.Api/Models/LoginRequest.cs`
|
|
|
|
**Step 1: Update AuthController imports and constructor**
|
|
|
|
Replace the using statements at the top of `AuthController.cs`:
|
|
|
|
```csharp
|
|
using System.Security.Claims;
|
|
using System.Text.Json;
|
|
using JdeScoping.Api.Extensions;
|
|
using JdeScoping.Core.Interfaces;
|
|
using JdeScoping.Core.Models;
|
|
using JdeScoping.Core.Models.Auth;
|
|
using Microsoft.AspNetCore.Authentication;
|
|
using Microsoft.AspNetCore.Authentication.Cookies;
|
|
using Microsoft.AspNetCore.Authorization;
|
|
using Microsoft.AspNetCore.Http;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using Microsoft.Extensions.Logging;
|
|
```
|
|
|
|
Update the constructor to inject IRsaKeyService:
|
|
|
|
```csharp
|
|
public class AuthController : ApiControllerBase
|
|
{
|
|
private readonly IAuthService _authService;
|
|
private readonly IRsaKeyService _rsaKeyService;
|
|
private readonly ILogger<AuthController> _logger;
|
|
|
|
public AuthController(
|
|
IAuthService authService,
|
|
IRsaKeyService rsaKeyService,
|
|
ILogger<AuthController> logger)
|
|
{
|
|
_authService = authService;
|
|
_rsaKeyService = rsaKeyService;
|
|
_logger = logger;
|
|
}
|
|
```
|
|
|
|
**Step 2: Add GetPublicKey endpoint**
|
|
|
|
Add after the constructor:
|
|
|
|
```csharp
|
|
/// <summary>
|
|
/// Gets the server's RSA public key for encrypting login credentials.
|
|
/// </summary>
|
|
[HttpGet("public-key")]
|
|
[AllowAnonymous]
|
|
[ProducesResponseType(typeof(PublicKeyResponse), StatusCodes.Status200OK)]
|
|
public ActionResult<PublicKeyResponse> GetPublicKey()
|
|
{
|
|
var publicKeyPem = _rsaKeyService.GetPublicKeyPem();
|
|
return Ok(new PublicKeyResponse(publicKeyPem));
|
|
}
|
|
```
|
|
|
|
**Step 3: Update Login endpoint to accept encrypted payload**
|
|
|
|
Replace the existing Login method:
|
|
|
|
```csharp
|
|
/// <summary>
|
|
/// Authenticates a user with encrypted credentials and creates a session cookie.
|
|
/// </summary>
|
|
/// <param name="request">Encrypted login credentials</param>
|
|
/// <param name="ct">Cancellation token</param>
|
|
/// <returns>Login result with user info on success</returns>
|
|
[HttpPost("login")]
|
|
[AllowAnonymous]
|
|
[ProducesResponseType(typeof(LoginResultModel), StatusCodes.Status200OK)]
|
|
[ProducesResponseType(typeof(LoginResultModel), StatusCodes.Status401Unauthorized)]
|
|
public async Task<ActionResult<LoginResultModel>> Login(
|
|
[FromBody] EncryptedLoginRequest request,
|
|
CancellationToken ct)
|
|
{
|
|
LoginModel loginModel;
|
|
try
|
|
{
|
|
var ciphertext = Convert.FromBase64String(request.EncryptedData);
|
|
var plaintext = _rsaKeyService.Decrypt(ciphertext);
|
|
loginModel = JsonSerializer.Deserialize<LoginModel>(plaintext)
|
|
?? throw new InvalidOperationException("Deserialization returned null");
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogWarning(ex, "Failed to decrypt login request");
|
|
return BadRequest(new LoginResultModel(false, "Invalid encrypted payload", null));
|
|
}
|
|
|
|
var result = await _authService.AuthenticateAsync(
|
|
loginModel.Username, loginModel.Password, ct);
|
|
|
|
if (!result.Success)
|
|
{
|
|
_logger.LogWarning("Failed login attempt for user {Username}", loginModel.Username);
|
|
return Unauthorized(new LoginResultModel(false, result.ErrorMessage, null));
|
|
}
|
|
|
|
// Sign out existing session
|
|
await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
|
|
|
|
// Create claims identity from user info
|
|
var identity = ClaimsExtensions.FromUserInfo(result.User!);
|
|
var principal = new ClaimsPrincipal(identity);
|
|
|
|
// Sign in with non-persistent cookie
|
|
await HttpContext.SignInAsync(
|
|
CookieAuthenticationDefaults.AuthenticationScheme,
|
|
principal,
|
|
new AuthenticationProperties { IsPersistent = false });
|
|
|
|
_logger.LogInformation("User {Username} logged in successfully", loginModel.Username);
|
|
return Ok(new LoginResultModel(true, null, result.User));
|
|
}
|
|
```
|
|
|
|
**Step 4: Delete old LoginRequest model**
|
|
|
|
Delete file: `NEW/src/JdeScoping.Api/Models/LoginRequest.cs`
|
|
|
|
**Step 5: Verify build succeeds**
|
|
|
|
Run: `dotnet build NEW/src/JdeScoping.Api/JdeScoping.Api.csproj`
|
|
Expected: Build succeeded
|
|
|
|
**Step 6: Commit**
|
|
|
|
```bash
|
|
git add NEW/src/JdeScoping.Api/Controllers/AuthController.cs
|
|
git rm NEW/src/JdeScoping.Api/Models/LoginRequest.cs
|
|
git commit -m "feat(api): update AuthController for encrypted login"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 6: Update AuthController Tests
|
|
|
|
**Files:**
|
|
- Modify: `NEW/tests/JdeScoping.Api.Tests/Controllers/AuthControllerTests.cs`
|
|
|
|
**Step 1: Update imports and add RSA helper**
|
|
|
|
Replace the entire test file:
|
|
|
|
```csharp
|
|
// NEW/tests/JdeScoping.Api.Tests/Controllers/AuthControllerTests.cs
|
|
using System.Security.Claims;
|
|
using System.Security.Cryptography;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
using JdeScoping.Api.Controllers;
|
|
using JdeScoping.Core.Interfaces;
|
|
using JdeScoping.Core.Models;
|
|
using JdeScoping.Core.Models.Auth;
|
|
using Microsoft.AspNetCore.Authentication;
|
|
using Microsoft.AspNetCore.Http;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using Microsoft.Extensions.Logging;
|
|
using NSubstitute;
|
|
using Shouldly;
|
|
|
|
namespace JdeScoping.Api.Tests.Controllers;
|
|
|
|
public class AuthControllerTests
|
|
{
|
|
private readonly IAuthService _authService;
|
|
private readonly IRsaKeyService _rsaKeyService;
|
|
private readonly ILogger<AuthController> _logger;
|
|
private readonly AuthController _controller;
|
|
private readonly RSA _testRsa;
|
|
|
|
public AuthControllerTests()
|
|
{
|
|
_authService = Substitute.For<IAuthService>();
|
|
_rsaKeyService = Substitute.For<IRsaKeyService>();
|
|
_logger = Substitute.For<ILogger<AuthController>>();
|
|
|
|
// Setup test RSA key pair
|
|
_testRsa = RSA.Create(2048);
|
|
var publicKeyPem = _testRsa.ExportSubjectPublicKeyInfoPem();
|
|
_rsaKeyService.GetPublicKeyPem().Returns(publicKeyPem);
|
|
_rsaKeyService.Decrypt(Arg.Any<byte[]>())
|
|
.Returns(callInfo =>
|
|
{
|
|
var ciphertext = callInfo.Arg<byte[]>();
|
|
return _testRsa.Decrypt(ciphertext, RSAEncryptionPadding.OaepSHA256);
|
|
});
|
|
|
|
_controller = new AuthController(_authService, _rsaKeyService, _logger);
|
|
}
|
|
|
|
private EncryptedLoginRequest EncryptLoginModel(LoginModel model)
|
|
{
|
|
var json = JsonSerializer.Serialize(model);
|
|
var plaintext = Encoding.UTF8.GetBytes(json);
|
|
var ciphertext = _testRsa.Encrypt(plaintext, RSAEncryptionPadding.OaepSHA256);
|
|
return new EncryptedLoginRequest(Convert.ToBase64String(ciphertext));
|
|
}
|
|
|
|
[Fact]
|
|
public void GetPublicKey_ReturnsPublicKeyPem()
|
|
{
|
|
// Act
|
|
var result = _controller.GetPublicKey();
|
|
|
|
// Assert
|
|
result.Result.ShouldBeOfType<OkObjectResult>();
|
|
var okResult = (OkObjectResult)result.Result!;
|
|
var response = okResult.Value.ShouldBeOfType<PublicKeyResponse>();
|
|
response.PublicKeyPem.ShouldStartWith("-----BEGIN PUBLIC KEY-----");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Login_WithValidCredentials_ReturnsLoginResultWithUser()
|
|
{
|
|
// Arrange
|
|
var loginModel = new LoginModel { Username = "testuser", Password = "password123" };
|
|
var request = EncryptLoginModel(loginModel);
|
|
var user = new UserInfo
|
|
{
|
|
Dn = "CN=testuser,DC=example,DC=com",
|
|
Username = "testuser",
|
|
FirstName = "Test",
|
|
LastName = "User",
|
|
EmailAddress = "test@example.com",
|
|
Title = "Developer"
|
|
};
|
|
_authService.AuthenticateAsync("testuser", "password123", Arg.Any<CancellationToken>())
|
|
.Returns(new AuthResult(true, user, null));
|
|
|
|
var httpContext = CreateMockHttpContext();
|
|
_controller.ControllerContext = new ControllerContext { HttpContext = httpContext };
|
|
|
|
// Act
|
|
var result = await _controller.Login(request, CancellationToken.None);
|
|
|
|
// Assert
|
|
result.Result.ShouldBeOfType<OkObjectResult>();
|
|
var okResult = (OkObjectResult)result.Result!;
|
|
var loginResult = okResult.Value.ShouldBeOfType<LoginResultModel>();
|
|
loginResult.Success.ShouldBeTrue();
|
|
loginResult.User.ShouldNotBeNull();
|
|
loginResult.User.Username.ShouldBe("testuser");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Login_WithInvalidCredentials_Returns401WithError()
|
|
{
|
|
// Arrange
|
|
var loginModel = new LoginModel { Username = "testuser", Password = "wrongpassword" };
|
|
var request = EncryptLoginModel(loginModel);
|
|
_authService.AuthenticateAsync("testuser", "wrongpassword", Arg.Any<CancellationToken>())
|
|
.Returns(new AuthResult(false, null, "Incorrect username or password"));
|
|
|
|
var httpContext = CreateMockHttpContext();
|
|
_controller.ControllerContext = new ControllerContext { HttpContext = httpContext };
|
|
|
|
// Act
|
|
var result = await _controller.Login(request, CancellationToken.None);
|
|
|
|
// Assert
|
|
result.Result.ShouldBeOfType<UnauthorizedObjectResult>();
|
|
var unauthorizedResult = (UnauthorizedObjectResult)result.Result!;
|
|
var loginResult = unauthorizedResult.Value.ShouldBeOfType<LoginResultModel>();
|
|
loginResult.Success.ShouldBeFalse();
|
|
loginResult.ErrorMessage.ShouldBe("Incorrect username or password");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Login_WithInvalidEncryptedData_ReturnsBadRequest()
|
|
{
|
|
// Arrange
|
|
var request = new EncryptedLoginRequest("not-valid-base64!!!");
|
|
|
|
var httpContext = CreateMockHttpContext();
|
|
_controller.ControllerContext = new ControllerContext { HttpContext = httpContext };
|
|
|
|
// Act
|
|
var result = await _controller.Login(request, CancellationToken.None);
|
|
|
|
// Assert
|
|
result.Result.ShouldBeOfType<BadRequestObjectResult>();
|
|
var badRequestResult = (BadRequestObjectResult)result.Result!;
|
|
var loginResult = badRequestResult.Value.ShouldBeOfType<LoginResultModel>();
|
|
loginResult.Success.ShouldBeFalse();
|
|
loginResult.ErrorMessage.ShouldBe("Invalid encrypted payload");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Logout_ClearsAuthentication()
|
|
{
|
|
// Arrange
|
|
var httpContext = CreateAuthenticatedHttpContext("testuser");
|
|
_controller.ControllerContext = new ControllerContext { HttpContext = httpContext };
|
|
|
|
// Act
|
|
var result = await _controller.Logout();
|
|
|
|
// Assert
|
|
result.ShouldBeOfType<OkResult>();
|
|
}
|
|
|
|
[Fact]
|
|
public void GetCurrentUser_WhenAuthenticated_ReturnsUserInfo()
|
|
{
|
|
// Arrange
|
|
var claims = new List<Claim>
|
|
{
|
|
new(ClaimTypes.Name, "testuser"),
|
|
new(ClaimTypes.GivenName, "Test"),
|
|
new(ClaimTypes.Surname, "User"),
|
|
new(ClaimTypes.Email, "test@example.com"),
|
|
new("title", "Developer"),
|
|
new("dn", "CN=testuser,DC=example,DC=com")
|
|
};
|
|
var identity = new ClaimsIdentity(claims, "Test");
|
|
var principal = new ClaimsPrincipal(identity);
|
|
|
|
var httpContext = new DefaultHttpContext { User = principal };
|
|
_controller.ControllerContext = new ControllerContext { HttpContext = httpContext };
|
|
|
|
// Act
|
|
var result = _controller.GetCurrentUser();
|
|
|
|
// Assert
|
|
result.Result.ShouldBeOfType<OkObjectResult>();
|
|
var okResult = (OkObjectResult)result.Result!;
|
|
var user = okResult.Value.ShouldBeOfType<UserInfo>();
|
|
user.Username.ShouldBe("testuser");
|
|
}
|
|
|
|
private static HttpContext CreateMockHttpContext()
|
|
{
|
|
var authServiceMock = Substitute.For<IAuthenticationService>();
|
|
authServiceMock.SignOutAsync(Arg.Any<HttpContext>(), Arg.Any<string>(), Arg.Any<AuthenticationProperties>())
|
|
.Returns(Task.CompletedTask);
|
|
authServiceMock.SignInAsync(Arg.Any<HttpContext>(), Arg.Any<string>(), Arg.Any<ClaimsPrincipal>(), Arg.Any<AuthenticationProperties>())
|
|
.Returns(Task.CompletedTask);
|
|
|
|
var serviceProvider = Substitute.For<IServiceProvider>();
|
|
serviceProvider.GetService(typeof(IAuthenticationService)).Returns(authServiceMock);
|
|
|
|
var httpContext = new DefaultHttpContext
|
|
{
|
|
RequestServices = serviceProvider
|
|
};
|
|
|
|
return httpContext;
|
|
}
|
|
|
|
private static HttpContext CreateAuthenticatedHttpContext(string username)
|
|
{
|
|
var httpContext = CreateMockHttpContext();
|
|
var claims = new List<Claim>
|
|
{
|
|
new(ClaimTypes.Name, username),
|
|
new("dn", $"CN={username},DC=example,DC=com")
|
|
};
|
|
var identity = new ClaimsIdentity(claims, "Test");
|
|
httpContext.User = new ClaimsPrincipal(identity);
|
|
return httpContext;
|
|
}
|
|
}
|
|
```
|
|
|
|
**Step 2: Run tests to verify they pass**
|
|
|
|
Run: `dotnet test NEW/tests/JdeScoping.Api.Tests --filter "FullyQualifiedName~AuthControllerTests" --verbosity normal`
|
|
Expected: All 6 tests pass
|
|
|
|
**Step 3: Commit**
|
|
|
|
```bash
|
|
git add NEW/tests/JdeScoping.Api.Tests/Controllers/AuthControllerTests.cs
|
|
git commit -m "test(api): update AuthController tests for encrypted login"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 7: Add Blazor.SubtleCrypto Package to Client
|
|
|
|
**Files:**
|
|
- Modify: `NEW/src/JdeScoping.Client/JdeScoping.Client.csproj`
|
|
|
|
**Step 1: Add package reference**
|
|
|
|
Run: `dotnet add NEW/src/JdeScoping.Client/JdeScoping.Client.csproj package Blazor.SubtleCrypto`
|
|
|
|
**Step 2: Verify build succeeds**
|
|
|
|
Run: `dotnet build NEW/src/JdeScoping.Client/JdeScoping.Client.csproj`
|
|
Expected: Build succeeded
|
|
|
|
**Step 3: Commit**
|
|
|
|
```bash
|
|
git add NEW/src/JdeScoping.Client/JdeScoping.Client.csproj
|
|
git commit -m "chore(client): add Blazor.SubtleCrypto package"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 8: Create ICryptoService and Implementation
|
|
|
|
**Files:**
|
|
- Create: `NEW/src/JdeScoping.Client/Services/ICryptoService.cs`
|
|
- Create: `NEW/src/JdeScoping.Client/Services/CryptoService.cs`
|
|
|
|
**Step 1: Create ICryptoService interface**
|
|
|
|
```csharp
|
|
// NEW/src/JdeScoping.Client/Services/ICryptoService.cs
|
|
using JdeScoping.Core.Models.Auth;
|
|
|
|
namespace JdeScoping.Client.Services;
|
|
|
|
/// <summary>
|
|
/// Service for encrypting data using server's RSA public key.
|
|
/// </summary>
|
|
public interface ICryptoService
|
|
{
|
|
/// <summary>
|
|
/// Encrypts login credentials for transmission to server.
|
|
/// </summary>
|
|
/// <param name="model">Login credentials to encrypt</param>
|
|
/// <returns>Base64-encoded encrypted data</returns>
|
|
Task<string> EncryptLoginAsync(LoginModel model);
|
|
}
|
|
```
|
|
|
|
**Step 2: Create CryptoService implementation**
|
|
|
|
```csharp
|
|
// NEW/src/JdeScoping.Client/Services/CryptoService.cs
|
|
using System.Net.Http.Json;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
using Blazor.SubtleCrypto;
|
|
using JdeScoping.Core.Models.Auth;
|
|
|
|
namespace JdeScoping.Client.Services;
|
|
|
|
/// <summary>
|
|
/// Encrypts login credentials using Web Crypto API via Blazor.SubtleCrypto.
|
|
/// </summary>
|
|
public class CryptoService : ICryptoService
|
|
{
|
|
private readonly HttpClient _httpClient;
|
|
private readonly ICryptoService _cryptoProvider;
|
|
private CryptoKey? _serverPublicKey;
|
|
private readonly SemaphoreSlim _keyLock = new(1, 1);
|
|
|
|
public CryptoService(HttpClient httpClient, ICryptoService cryptoProvider)
|
|
{
|
|
_httpClient = httpClient;
|
|
_cryptoProvider = cryptoProvider;
|
|
}
|
|
|
|
public async Task<string> EncryptLoginAsync(LoginModel model)
|
|
{
|
|
var publicKey = await GetOrFetchPublicKeyAsync();
|
|
|
|
var json = JsonSerializer.Serialize(model);
|
|
var plaintext = Encoding.UTF8.GetBytes(json);
|
|
|
|
var encrypted = await _cryptoProvider.EncryptAsync(
|
|
plaintext,
|
|
publicKey,
|
|
new EncryptParams { Name = "RSA-OAEP" });
|
|
|
|
return Convert.ToBase64String(encrypted);
|
|
}
|
|
|
|
private async Task<CryptoKey> GetOrFetchPublicKeyAsync()
|
|
{
|
|
if (_serverPublicKey is not null)
|
|
return _serverPublicKey;
|
|
|
|
await _keyLock.WaitAsync();
|
|
try
|
|
{
|
|
if (_serverPublicKey is not null)
|
|
return _serverPublicKey;
|
|
|
|
var response = await _httpClient.GetFromJsonAsync<PublicKeyResponse>("api/auth/public-key")
|
|
?? throw new InvalidOperationException("Failed to fetch public key");
|
|
|
|
_serverPublicKey = await _cryptoProvider.ImportKeyAsync(
|
|
"spki",
|
|
Convert.FromBase64String(ExtractBase64FromPem(response.PublicKeyPem)),
|
|
new Algorithm { Name = "RSA-OAEP", Hash = "SHA-256" },
|
|
false,
|
|
new[] { "encrypt" });
|
|
|
|
return _serverPublicKey;
|
|
}
|
|
finally
|
|
{
|
|
_keyLock.Release();
|
|
}
|
|
}
|
|
|
|
private static string ExtractBase64FromPem(string pem)
|
|
{
|
|
return pem
|
|
.Replace("-----BEGIN PUBLIC KEY-----", "")
|
|
.Replace("-----END PUBLIC KEY-----", "")
|
|
.Replace("\n", "")
|
|
.Replace("\r", "")
|
|
.Trim();
|
|
}
|
|
}
|
|
```
|
|
|
|
**Step 3: Verify build succeeds**
|
|
|
|
Run: `dotnet build NEW/src/JdeScoping.Client/JdeScoping.Client.csproj`
|
|
Expected: Build succeeded (may have warnings about Blazor.SubtleCrypto API - we'll fix in next step if needed)
|
|
|
|
**Step 4: Commit**
|
|
|
|
```bash
|
|
git add NEW/src/JdeScoping.Client/Services/ICryptoService.cs
|
|
git add NEW/src/JdeScoping.Client/Services/CryptoService.cs
|
|
git commit -m "feat(client): add CryptoService for login encryption"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 9: Update Client AuthService and IAuthService
|
|
|
|
**Files:**
|
|
- Modify: `NEW/src/JdeScoping.Client/Services/IAuthService.cs`
|
|
- Modify: `NEW/src/JdeScoping.Client/Services/AuthService.cs`
|
|
- Delete: `NEW/src/JdeScoping.Client/Models/LoginModel.cs`
|
|
|
|
**Step 1: Update IAuthService to use shared models**
|
|
|
|
Replace the entire file:
|
|
|
|
```csharp
|
|
// NEW/src/JdeScoping.Client/Services/IAuthService.cs
|
|
using JdeScoping.Core.Models.Auth;
|
|
|
|
namespace JdeScoping.Client.Services;
|
|
|
|
/// <summary>
|
|
/// Service for authentication operations.
|
|
/// </summary>
|
|
public interface IAuthService
|
|
{
|
|
/// <summary>
|
|
/// Attempts to log in with the provided credentials (encrypted).
|
|
/// </summary>
|
|
Task<LoginResultModel> LoginAsync(LoginModel model);
|
|
|
|
/// <summary>
|
|
/// Logs out the current user.
|
|
/// </summary>
|
|
Task LogoutAsync();
|
|
}
|
|
```
|
|
|
|
**Step 2: Update AuthService to use encrypted login**
|
|
|
|
Replace the entire file:
|
|
|
|
```csharp
|
|
// NEW/src/JdeScoping.Client/Services/AuthService.cs
|
|
using System.Net.Http.Json;
|
|
using JdeScoping.Client.Auth;
|
|
using JdeScoping.Core.Models.Auth;
|
|
|
|
namespace JdeScoping.Client.Services;
|
|
|
|
/// <summary>
|
|
/// Handles authentication via encrypted API calls with cookie-based auth.
|
|
/// </summary>
|
|
public class AuthService : IAuthService
|
|
{
|
|
private readonly HttpClient _httpClient;
|
|
private readonly ICryptoService _cryptoService;
|
|
private readonly AuthStateProvider _authStateProvider;
|
|
|
|
public AuthService(
|
|
HttpClient httpClient,
|
|
ICryptoService cryptoService,
|
|
AuthStateProvider authStateProvider)
|
|
{
|
|
_httpClient = httpClient;
|
|
_cryptoService = cryptoService;
|
|
_authStateProvider = authStateProvider;
|
|
}
|
|
|
|
public async Task<LoginResultModel> LoginAsync(LoginModel model)
|
|
{
|
|
try
|
|
{
|
|
// Encrypt credentials
|
|
var encryptedData = await _cryptoService.EncryptLoginAsync(model);
|
|
var request = new EncryptedLoginRequest(encryptedData);
|
|
|
|
// Send encrypted request
|
|
var response = await _httpClient.PostAsJsonAsync("api/auth/login", request);
|
|
|
|
var result = await response.Content.ReadFromJsonAsync<LoginResultModel>();
|
|
if (result is null)
|
|
{
|
|
return new LoginResultModel(false, "Invalid response from server", null);
|
|
}
|
|
|
|
if (result.Success && result.User is not null)
|
|
{
|
|
// Notify auth state provider of the login
|
|
var userViewModel = new UserInfoViewModel
|
|
{
|
|
Username = result.User.Username,
|
|
FirstName = result.User.FirstName,
|
|
LastName = result.User.LastName,
|
|
EmailAddress = result.User.EmailAddress,
|
|
Title = result.User.Title,
|
|
Dn = result.User.Dn
|
|
};
|
|
await _authStateProvider.MarkUserAsAuthenticated(userViewModel);
|
|
}
|
|
|
|
return result;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
return new LoginResultModel(false, $"Login failed: {ex.Message}", null);
|
|
}
|
|
}
|
|
|
|
public async Task LogoutAsync()
|
|
{
|
|
try
|
|
{
|
|
await _httpClient.PostAsync("api/auth/logout", null);
|
|
}
|
|
catch
|
|
{
|
|
// Even if logout API fails, clear local state
|
|
}
|
|
|
|
await _authStateProvider.LogoutAsync();
|
|
}
|
|
}
|
|
```
|
|
|
|
**Step 3: Delete old LoginModel**
|
|
|
|
Delete file: `NEW/src/JdeScoping.Client/Models/LoginModel.cs`
|
|
|
|
**Step 4: Verify build succeeds**
|
|
|
|
Run: `dotnet build NEW/src/JdeScoping.Client/JdeScoping.Client.csproj`
|
|
Expected: Build succeeded
|
|
|
|
**Step 5: Commit**
|
|
|
|
```bash
|
|
git add NEW/src/JdeScoping.Client/Services/IAuthService.cs
|
|
git add NEW/src/JdeScoping.Client/Services/AuthService.cs
|
|
git rm NEW/src/JdeScoping.Client/Models/LoginModel.cs
|
|
git commit -m "feat(client): update AuthService to use encrypted login"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 10: Update Login.razor to Use Shared Model
|
|
|
|
**Files:**
|
|
- Modify: `NEW/src/JdeScoping.Client/Pages/Login.razor`
|
|
|
|
**Step 1: Update the using statement and result handling**
|
|
|
|
Change line 1 and add using at the top:
|
|
|
|
```razor
|
|
@page "/login"
|
|
@using JdeScoping.Core.Models.Auth
|
|
@inject IAuthService AuthService
|
|
@inject NavigationManager NavigationManager
|
|
```
|
|
|
|
Update the HandleLoginAsync method (in the @code block) to use LoginResultModel:
|
|
|
|
```csharp
|
|
private async Task HandleLoginAsync()
|
|
{
|
|
_isLoading = true;
|
|
_errorMessage = null;
|
|
|
|
try
|
|
{
|
|
var result = await AuthService.LoginAsync(_loginModel);
|
|
|
|
if (result.Success)
|
|
{
|
|
var returnUrl = string.IsNullOrEmpty(ReturnUrl) ? "/" : ReturnUrl;
|
|
NavigationManager.NavigateTo(returnUrl);
|
|
}
|
|
else
|
|
{
|
|
_errorMessage = result.ErrorMessage ?? "Login failed. Please check your credentials.";
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_errorMessage = $"An error occurred: {ex.Message}";
|
|
}
|
|
finally
|
|
{
|
|
_isLoading = false;
|
|
}
|
|
}
|
|
```
|
|
|
|
**Step 2: Verify build succeeds**
|
|
|
|
Run: `dotnet build NEW/src/JdeScoping.Client/JdeScoping.Client.csproj`
|
|
Expected: Build succeeded
|
|
|
|
**Step 3: Commit**
|
|
|
|
```bash
|
|
git add NEW/src/JdeScoping.Client/Pages/Login.razor
|
|
git commit -m "feat(client): update Login.razor to use shared LoginModel"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 11: Register CryptoService in Client DI
|
|
|
|
**Files:**
|
|
- Modify: `NEW/src/JdeScoping.Client/Program.cs`
|
|
|
|
**Step 1: Add CryptoService registration**
|
|
|
|
Add after line 7 (after existing using statements):
|
|
|
|
```csharp
|
|
using Blazor.SubtleCrypto;
|
|
```
|
|
|
|
Add after line 26 (after AuthStateProvider registration, before IAuthService):
|
|
|
|
```csharp
|
|
// Crypto service for login encryption
|
|
builder.Services.AddSubtleCrypto();
|
|
builder.Services.AddScoped<ICryptoService, CryptoService>();
|
|
```
|
|
|
|
Update IAuthService registration (line 28) - it should still work as AuthService now takes ICryptoService.
|
|
|
|
**Step 2: Verify build succeeds**
|
|
|
|
Run: `dotnet build NEW/src/JdeScoping.Client/JdeScoping.Client.csproj`
|
|
Expected: Build succeeded
|
|
|
|
**Step 3: Commit**
|
|
|
|
```bash
|
|
git add NEW/src/JdeScoping.Client/Program.cs
|
|
git commit -m "chore(client): register CryptoService in DI"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 12: Add Client CryptoService Tests
|
|
|
|
**Files:**
|
|
- Create: `NEW/tests/JdeScoping.Client.Tests/Services/CryptoServiceTests.cs`
|
|
- Modify: `NEW/tests/JdeScoping.Client.Tests/JdeScoping.Client.Tests.csproj`
|
|
- Delete: `NEW/tests/JdeScoping.Client.Tests/Placeholder.cs`
|
|
|
|
**Step 1: Update Client.Tests csproj to add required packages**
|
|
|
|
Add to ItemGroup with PackageReferences:
|
|
|
|
```xml
|
|
<PackageReference Include="Blazor.SubtleCrypto" Version="..." />
|
|
<PackageReference Include="RichardSzalay.MockHttp" Version="7.0.0" />
|
|
```
|
|
|
|
Also add project reference to Core:
|
|
|
|
```xml
|
|
<ItemGroup>
|
|
<ProjectReference Include="..\..\src\JdeScoping.Client\JdeScoping.Client.csproj" />
|
|
<ProjectReference Include="..\..\src\JdeScoping.Core\JdeScoping.Core.csproj" />
|
|
</ItemGroup>
|
|
```
|
|
|
|
**Step 2: Create CryptoServiceTests**
|
|
|
|
Note: Testing Blazor.SubtleCrypto directly is challenging because it requires browser APIs. We'll create integration-style tests that verify the service behavior with mocks.
|
|
|
|
```csharp
|
|
// NEW/tests/JdeScoping.Client.Tests/Services/CryptoServiceTests.cs
|
|
using System.Net;
|
|
using System.Net.Http.Json;
|
|
using System.Security.Cryptography;
|
|
using System.Text.Json;
|
|
using JdeScoping.Client.Services;
|
|
using JdeScoping.Core.Models.Auth;
|
|
using NSubstitute;
|
|
using RichardSzalay.MockHttp;
|
|
using Shouldly;
|
|
|
|
namespace JdeScoping.Client.Tests.Services;
|
|
|
|
public class CryptoServiceTests
|
|
{
|
|
[Fact]
|
|
public async Task EncryptLoginAsync_FetchesPublicKeyOnce()
|
|
{
|
|
// Arrange
|
|
using var rsa = RSA.Create(2048);
|
|
var publicKeyPem = rsa.ExportSubjectPublicKeyInfoPem();
|
|
var mockHttp = new MockHttpMessageHandler();
|
|
|
|
// Setup public key endpoint - should only be called once
|
|
var keyRequest = mockHttp.Expect("/api/auth/public-key")
|
|
.Respond("application/json", JsonSerializer.Serialize(new PublicKeyResponse(publicKeyPem)));
|
|
|
|
var httpClient = new HttpClient(mockHttp) { BaseAddress = new Uri("http://localhost/") };
|
|
|
|
// Create a mock crypto provider that just returns dummy encrypted data
|
|
var cryptoProvider = Substitute.For<Blazor.SubtleCrypto.ICryptoService>();
|
|
cryptoProvider.ImportKeyAsync(
|
|
Arg.Any<string>(), Arg.Any<byte[]>(), Arg.Any<Blazor.SubtleCrypto.Algorithm>(),
|
|
Arg.Any<bool>(), Arg.Any<string[]>())
|
|
.Returns(new Blazor.SubtleCrypto.CryptoKey());
|
|
cryptoProvider.EncryptAsync(Arg.Any<byte[]>(), Arg.Any<Blazor.SubtleCrypto.CryptoKey>(), Arg.Any<Blazor.SubtleCrypto.EncryptParams>())
|
|
.Returns(new byte[] { 1, 2, 3, 4 });
|
|
|
|
var service = new CryptoService(httpClient, cryptoProvider);
|
|
var loginModel = new LoginModel { Username = "test", Password = "pass" };
|
|
|
|
// Act - call twice
|
|
await service.EncryptLoginAsync(loginModel);
|
|
await service.EncryptLoginAsync(loginModel);
|
|
|
|
// Assert - public key fetched only once
|
|
mockHttp.GetMatchCount(keyRequest).ShouldBe(1);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task EncryptLoginAsync_ReturnsBase64String()
|
|
{
|
|
// Arrange
|
|
using var rsa = RSA.Create(2048);
|
|
var publicKeyPem = rsa.ExportSubjectPublicKeyInfoPem();
|
|
var mockHttp = new MockHttpMessageHandler();
|
|
mockHttp.When("/api/auth/public-key")
|
|
.Respond("application/json", JsonSerializer.Serialize(new PublicKeyResponse(publicKeyPem)));
|
|
|
|
var httpClient = new HttpClient(mockHttp) { BaseAddress = new Uri("http://localhost/") };
|
|
|
|
var cryptoProvider = Substitute.For<Blazor.SubtleCrypto.ICryptoService>();
|
|
cryptoProvider.ImportKeyAsync(
|
|
Arg.Any<string>(), Arg.Any<byte[]>(), Arg.Any<Blazor.SubtleCrypto.Algorithm>(),
|
|
Arg.Any<bool>(), Arg.Any<string[]>())
|
|
.Returns(new Blazor.SubtleCrypto.CryptoKey());
|
|
cryptoProvider.EncryptAsync(Arg.Any<byte[]>(), Arg.Any<Blazor.SubtleCrypto.CryptoKey>(), Arg.Any<Blazor.SubtleCrypto.EncryptParams>())
|
|
.Returns(new byte[] { 1, 2, 3, 4 });
|
|
|
|
var service = new CryptoService(httpClient, cryptoProvider);
|
|
var loginModel = new LoginModel { Username = "test", Password = "pass" };
|
|
|
|
// Act
|
|
var result = await service.EncryptLoginAsync(loginModel);
|
|
|
|
// Assert - should be valid base64
|
|
var decoded = Convert.FromBase64String(result);
|
|
decoded.ShouldNotBeEmpty();
|
|
}
|
|
}
|
|
```
|
|
|
|
**Step 3: Delete placeholder file**
|
|
|
|
Delete: `NEW/tests/JdeScoping.Client.Tests/Placeholder.cs`
|
|
|
|
**Step 4: Run tests to verify they pass**
|
|
|
|
Run: `dotnet test NEW/tests/JdeScoping.Client.Tests --verbosity normal`
|
|
Expected: All tests pass
|
|
|
|
**Step 5: Commit**
|
|
|
|
```bash
|
|
git add NEW/tests/JdeScoping.Client.Tests/
|
|
git rm NEW/tests/JdeScoping.Client.Tests/Placeholder.cs
|
|
git commit -m "test(client): add CryptoService tests"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 13: Update Integration Tests
|
|
|
|
**Files:**
|
|
- Modify: `NEW/tests/JdeScoping.Api.IntegrationTests/AuthenticationTests.cs`
|
|
|
|
**Step 1: Update integration tests for encrypted login**
|
|
|
|
The integration tests need to use encrypted login requests. Update the test file to encrypt credentials before sending.
|
|
|
|
Read the current file first to understand its structure, then update to use RSA encryption similar to the unit tests.
|
|
|
|
**Step 2: Run all tests**
|
|
|
|
Run: `dotnet test NEW/tests --verbosity normal`
|
|
Expected: All tests pass
|
|
|
|
**Step 3: Commit**
|
|
|
|
```bash
|
|
git add NEW/tests/JdeScoping.Api.IntegrationTests/AuthenticationTests.cs
|
|
git commit -m "test(integration): update auth tests for encrypted login"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 14: Final Verification
|
|
|
|
**Step 1: Build entire solution**
|
|
|
|
Run: `dotnet build NEW/JdeScoping.sln`
|
|
Expected: Build succeeded with 0 errors
|
|
|
|
**Step 2: Run all tests**
|
|
|
|
Run: `dotnet test NEW/JdeScoping.sln --verbosity normal`
|
|
Expected: All tests pass
|
|
|
|
**Step 3: Verify deleted files are gone**
|
|
|
|
Confirm these files no longer exist:
|
|
- `NEW/src/JdeScoping.Api/Models/LoginRequest.cs`
|
|
- `NEW/src/JdeScoping.Client/Models/LoginModel.cs`
|
|
|
|
**Step 4: Final commit**
|
|
|
|
```bash
|
|
git add -A
|
|
git commit -m "feat: complete encrypted login implementation"
|
|
```
|
|
|
|
---
|
|
|
|
## Summary
|
|
|
|
**Files Created:**
|
|
- `NEW/src/JdeScoping.Core/Models/Auth/LoginModel.cs`
|
|
- `NEW/src/JdeScoping.Core/Models/Auth/LoginResultModel.cs`
|
|
- `NEW/src/JdeScoping.Core/Models/Auth/EncryptedLoginRequest.cs`
|
|
- `NEW/src/JdeScoping.Core/Models/Auth/PublicKeyResponse.cs`
|
|
- `NEW/src/JdeScoping.Core/Interfaces/IRsaKeyService.cs`
|
|
- `NEW/src/JdeScoping.Core/Options/RsaKeyOptions.cs`
|
|
- `NEW/src/JdeScoping.Infrastructure/Security/RsaKeyService.cs`
|
|
- `NEW/src/JdeScoping.Client/Services/ICryptoService.cs`
|
|
- `NEW/src/JdeScoping.Client/Services/CryptoService.cs`
|
|
- `NEW/tests/JdeScoping.Infrastructure.Tests/Security/RsaKeyServiceTests.cs`
|
|
- `NEW/tests/JdeScoping.Client.Tests/Services/CryptoServiceTests.cs`
|
|
|
|
**Files Modified:**
|
|
- `NEW/src/JdeScoping.Infrastructure/DependencyInjection.cs`
|
|
- `NEW/src/JdeScoping.Api/Controllers/AuthController.cs`
|
|
- `NEW/src/JdeScoping.Client/Services/IAuthService.cs`
|
|
- `NEW/src/JdeScoping.Client/Services/AuthService.cs`
|
|
- `NEW/src/JdeScoping.Client/Pages/Login.razor`
|
|
- `NEW/src/JdeScoping.Client/Program.cs`
|
|
- `NEW/src/JdeScoping.Client/JdeScoping.Client.csproj`
|
|
- `NEW/tests/JdeScoping.Api.Tests/Controllers/AuthControllerTests.cs`
|
|
- `NEW/tests/JdeScoping.Client.Tests/JdeScoping.Client.Tests.csproj`
|
|
- `NEW/tests/JdeScoping.Api.IntegrationTests/AuthenticationTests.cs`
|
|
|
|
**Files Deleted:**
|
|
- `NEW/src/JdeScoping.Api/Models/LoginRequest.cs`
|
|
- `NEW/src/JdeScoping.Client/Models/LoginModel.cs`
|
|
- `NEW/tests/JdeScoping.Client.Tests/Placeholder.cs`
|