Skip to content

Implement IProtectedUserStore for automatic personal data encryption #751

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 3 commits into
base: main
Choose a base branch
from
Draft
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
2 changes: 1 addition & 1 deletion EssentialCSharp.Web.Tests/EssentialCSharp.Web.Tests.csproj
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<TargetFramework>net8.0</TargetFramework>

<IsPackable>false</IsPackable>
<IsPublishable>false</IsPublishable>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
using EssentialCSharp.Web.Areas.Identity.Data;
using EssentialCSharp.Web.Services;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.DependencyInjection;
using Xunit;

namespace EssentialCSharp.Web.Tests;

/// <summary>
/// Integration tests for Personal Data Protection functionality with Identity User
/// </summary>
public class PersonalDataProtectionIntegrationTests
{
[Fact]
public void PersonalDataProtectionService_ImplementsIPersonalDataProtector()
{
// Arrange
var services = new ServiceCollection();
services.AddDataProtection();
var serviceProvider = services.BuildServiceProvider();
var dataProtectionProvider = serviceProvider.GetRequiredService<IDataProtectionProvider>();

// Act
var service = new PersonalDataProtectionService(dataProtectionProvider);

// Assert
Assert.IsAssignableFrom<IPersonalDataProtector>(service);
}

[Fact]
public void EssentialCSharpWebUser_HasProtectedPersonalDataAttributes()
{
// Arrange & Act
var user = new EssentialCSharpWebUser();
var firstNameProperty = typeof(EssentialCSharpWebUser).GetProperty(nameof(EssentialCSharpWebUser.FirstName));
var lastNameProperty = typeof(EssentialCSharpWebUser).GetProperty(nameof(EssentialCSharpWebUser.LastName));

// Assert
Assert.NotNull(firstNameProperty);
Assert.NotNull(lastNameProperty);

var firstNameAttributes = firstNameProperty.GetCustomAttributes(typeof(ProtectedPersonalDataAttribute), false);
var lastNameAttributes = lastNameProperty.GetCustomAttributes(typeof(ProtectedPersonalDataAttribute), false);

Assert.NotEmpty(firstNameAttributes);
Assert.NotEmpty(lastNameAttributes);
}

[Fact]
public void PersonalDataProtectionService_CanProtectUserPersonalData()
{
// Arrange
var services = new ServiceCollection();
services.AddDataProtection();
var serviceProvider = services.BuildServiceProvider();
var dataProtectionProvider = serviceProvider.GetRequiredService<IDataProtectionProvider>();
var service = new PersonalDataProtectionService(dataProtectionProvider);

var testFirstName = "John";
var testLastName = "Doe";

// Act
var protectedFirstName = service.Protect(testFirstName);
var protectedLastName = service.Protect(testLastName);

var unprotectedFirstName = service.Unprotect(protectedFirstName);
var unprotectedLastName = service.Unprotect(protectedLastName);

// Assert
Assert.NotEqual(testFirstName, protectedFirstName);
Assert.NotEqual(testLastName, protectedLastName);
Assert.Equal(testFirstName, unprotectedFirstName);
Assert.Equal(testLastName, unprotectedLastName);
}
}
112 changes: 112 additions & 0 deletions EssentialCSharp.Web.Tests/PersonalDataProtectionServiceTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
using EssentialCSharp.Web.Services;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Xunit;

namespace EssentialCSharp.Web.Tests;

public class PersonalDataProtectionServiceTests
{
private PersonalDataProtectionService CreateService()
{
var services = new ServiceCollection();
services.AddDataProtection();
var serviceProvider = services.BuildServiceProvider();
var dataProtectionProvider = serviceProvider.GetRequiredService<IDataProtectionProvider>();

return new PersonalDataProtectionService(dataProtectionProvider);
}

[Fact]
public void Protect_WithValidData_ReturnsEncryptedString()
{
// Arrange
var service = CreateService();
var testData = "John Doe";

// Act
var protectedData = service.Protect(testData);

// Assert
Assert.NotNull(protectedData);
Assert.NotEmpty(protectedData);
Assert.NotEqual(testData, protectedData);
}

[Fact]
public void Unprotect_WithProtectedData_ReturnsOriginalString()
{
// Arrange
var service = CreateService();
var testData = "Jane Smith";

// Act
var protectedData = service.Protect(testData);
var unprotectedData = service.Unprotect(protectedData);

// Assert
Assert.Equal(testData, unprotectedData);
}

[Theory]
[InlineData(null)]
[InlineData("")]
public void Protect_WithNullOrEmptyData_ReturnsEmptyString(string? testData)
{
// Arrange
var service = CreateService();

// Act
var result = service.Protect(testData);

// Assert
Assert.Equal(string.Empty, result);
}

[Theory]
[InlineData(null)]
[InlineData("")]
public void Unprotect_WithNullOrEmptyData_ReturnsEmptyString(string? testData)
{
// Arrange
var service = CreateService();

// Act
var result = service.Unprotect(testData);

// Assert
Assert.Equal(string.Empty, result);
}

[Fact]
public void Unprotect_WithUnencryptedData_ReturnsOriginalDataForBackwardCompatibility()
{
// Arrange
var service = CreateService();
var unencryptedData = "This is plain text data";

// Act
var result = service.Unprotect(unencryptedData);

// Assert
// Should return the original data when decryption fails (backward compatibility)
Assert.Equal(unencryptedData, result);
}

[Fact]
public void ProtectAndUnprotect_WithSpecialCharacters_WorksCorrectly()
{
// Arrange
var service = CreateService();
var testData = "Special chars: éñüñëç@#$%^&*()";

// Act
var protectedData = service.Protect(testData);
var unprotectedData = service.Unprotect(protectedData);

// Assert
Assert.Equal(testData, unprotectedData);
Assert.NotEqual(testData, protectedData);
}
}
2 changes: 1 addition & 1 deletion EssentialCSharp.Web/EssentialCSharp.Web.csproj
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<TargetFramework>net8.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<PlaceholderChapterOneHtmlFile Include="$(ProjectDir)/Placeholders/Chapters/01/Pages/*.html" />
Expand Down
53 changes: 30 additions & 23 deletions EssentialCSharp.Web/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -65,30 +65,37 @@ private static void Main(string[] args)
}
}

builder.Services.AddDbContext<EssentialCSharpWebContext>(options => options.UseSqlServer(connectionString));
builder.Services.AddDefaultIdentity<EssentialCSharpWebUser>(options =>
{
// Password settings
options.User.RequireUniqueEmail = true;
options.Password.RequiredLength = PasswordRequirementOptions.PasswordMinimumLength;
options.Password.RequireDigit = PasswordRequirementOptions.RequireDigit;
options.Password.RequireNonAlphanumeric = PasswordRequirementOptions.RequireNonAlphanumeric;
options.Password.RequireUppercase = PasswordRequirementOptions.RequireUppercase;
options.Password.RequireLowercase = PasswordRequirementOptions.RequireLowercase;
options.Password.RequiredUniqueChars = PasswordRequirementOptions.RequiredUniqueChars;

options.SignIn.RequireConfirmedEmail = true;
options.SignIn.RequireConfirmedAccount = true;

options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(30);
options.Lockout.MaxFailedAccessAttempts = 3;

//TODO: Implement IProtectedUserStore
//options.Stores.ProtectPersonalData = true;
builder.Services.AddDbContext<EssentialCSharpWebContext>(options => options.UseSqlServer(connectionString));

// Add Data Protection services
builder.Services.AddDataProtection();

builder.Services.AddDefaultIdentity<EssentialCSharpWebUser>(options =>
{
// Password settings
options.User.RequireUniqueEmail = true;
options.Password.RequiredLength = PasswordRequirementOptions.PasswordMinimumLength;
options.Password.RequireDigit = PasswordRequirementOptions.RequireDigit;
options.Password.RequireNonAlphanumeric = PasswordRequirementOptions.RequireNonAlphanumeric;
options.Password.RequireUppercase = PasswordRequirementOptions.RequireUppercase;
options.Password.RequireLowercase = PasswordRequirementOptions.RequireLowercase;
options.Password.RequiredUniqueChars = PasswordRequirementOptions.RequiredUniqueChars;

options.SignIn.RequireConfirmedEmail = true;
options.SignIn.RequireConfirmedAccount = true;

options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(30);
options.Lockout.MaxFailedAccessAttempts = 3;

// Enable personal data protection for properties marked with [ProtectedPersonalData]
options.Stores.ProtectPersonalData = true;
})
.AddEntityFrameworkStores<EssentialCSharpWebContext>()
.AddPasswordValidator<UsernameOrEmailAsPasswordValidator<EssentialCSharpWebUser>>()
.AddPasswordValidator<Top100000PasswordValidator<EssentialCSharpWebUser>>();
.AddEntityFrameworkStores<EssentialCSharpWebContext>()
.AddPasswordValidator<UsernameOrEmailAsPasswordValidator<EssentialCSharpWebUser>>()
.AddPasswordValidator<Top100000PasswordValidator<EssentialCSharpWebUser>>();

// Register personal data protector for IProtectedUserStore functionality
builder.Services.AddScoped<IPersonalDataProtector, PersonalDataProtectionService>();

builder.Configuration
.AddJsonFile("appsettings.json", optional: false, reloadOnChange: true)
Expand Down
57 changes: 57 additions & 0 deletions EssentialCSharp.Web/Services/PersonalDataProtectionService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.Identity;

namespace EssentialCSharp.Web.Services;

/// <summary>
/// Service for protecting and unprotecting personal data in the Identity user store
/// using ASP.NET Core Data Protection API.
/// </summary>
public class PersonalDataProtectionService : IPersonalDataProtector
{
private readonly IDataProtector _protector;

public PersonalDataProtectionService(IDataProtectionProvider provider)
{
_protector = provider.CreateProtector("Microsoft.AspNetCore.Identity.PersonalData");
}

/// <summary>
/// Protects (encrypts) the given personal data value.
/// </summary>
/// <param name="data">The data to protect</param>
/// <returns>The protected (encrypted) data as a string</returns>
public string Protect(string? data)
{
if (string.IsNullOrEmpty(data))
{
return string.Empty;
}

return _protector.Protect(data);
}

/// <summary>
/// Unprotects (decrypts) the given protected personal data value.
/// </summary>
/// <param name="data">The protected data to unprotect</param>
/// <returns>The unprotected (decrypted) data as a string</returns>
public string Unprotect(string? data)
{
if (string.IsNullOrEmpty(data))
{
return string.Empty;
}

try
{
return _protector.Unprotect(data);
}
catch (Exception)
{
// If decryption fails, assume the data is not encrypted (for backward compatibility)
// This handles cases where existing user data was stored before encryption was enabled
return data;
}
}
}
2 changes: 1 addition & 1 deletion global.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"sdk": {
"version": "9.0.101",
"version": "8.0.117",
"rollForward": "latestMinor"
}
}