Init commit

add security encryption and hashing: WIP
add endpoints: register user, get and save resources
add db main operations, User entity
This commit is contained in:
Alex Bezdieniezhnykh
2024-11-09 00:37:43 +02:00
commit 121052a3ef
26 changed files with 605 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
.idea
bin
obj
.vs
*.DotSettings*
*.user
log*
+34
View File
@@ -0,0 +1,34 @@
Microsoft Visual Studio Solution File, Format Version 12.00
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Azaion.Api", "Azaion.Api\Azaion.Api.csproj", "{03A56CF2-A57F-4631-8454-C08B804B8903}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Azaion.Common", "Azaion.Common\Azaion.Common.csproj", "{E838FA94-B96D-4446-B5D6-6BC1A34436C1}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Azaion.Services", "Azaion.Services\Azaion.Services.csproj", "{07CFFA74-A1ED-43F9-9CD4-5A09B320EF44}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Azaion.Test", "Azaion.Test\Azaion.Test.csproj", "{2F4F0EA9-0645-4917-8D21-F317E815EB9E}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{03A56CF2-A57F-4631-8454-C08B804B8903}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{03A56CF2-A57F-4631-8454-C08B804B8903}.Debug|Any CPU.Build.0 = Debug|Any CPU
{03A56CF2-A57F-4631-8454-C08B804B8903}.Release|Any CPU.ActiveCfg = Release|Any CPU
{03A56CF2-A57F-4631-8454-C08B804B8903}.Release|Any CPU.Build.0 = Release|Any CPU
{E838FA94-B96D-4446-B5D6-6BC1A34436C1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{E838FA94-B96D-4446-B5D6-6BC1A34436C1}.Debug|Any CPU.Build.0 = Debug|Any CPU
{E838FA94-B96D-4446-B5D6-6BC1A34436C1}.Release|Any CPU.ActiveCfg = Release|Any CPU
{E838FA94-B96D-4446-B5D6-6BC1A34436C1}.Release|Any CPU.Build.0 = Release|Any CPU
{07CFFA74-A1ED-43F9-9CD4-5A09B320EF44}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{07CFFA74-A1ED-43F9-9CD4-5A09B320EF44}.Debug|Any CPU.Build.0 = Debug|Any CPU
{07CFFA74-A1ED-43F9-9CD4-5A09B320EF44}.Release|Any CPU.ActiveCfg = Release|Any CPU
{07CFFA74-A1ED-43F9-9CD4-5A09B320EF44}.Release|Any CPU.Build.0 = Release|Any CPU
{2F4F0EA9-0645-4917-8D21-F317E815EB9E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{2F4F0EA9-0645-4917-8D21-F317E815EB9E}.Debug|Any CPU.Build.0 = Debug|Any CPU
{2F4F0EA9-0645-4917-8D21-F317E815EB9E}.Release|Any CPU.ActiveCfg = Release|Any CPU
{2F4F0EA9-0645-4917-8D21-F317E815EB9E}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
EndGlobal
+20
View File
@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FluentValidation.AspNetCore" Version="11.3.0" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.8"/>
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.4.0"/>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Azaion.Common\Azaion.Common.csproj" />
<ProjectReference Include="..\Azaion.Services\Azaion.Services.csproj" />
</ItemGroup>
</Project>
+6
View File
@@ -0,0 +1,6 @@
@Azaion.Api_HostAddress = http://localhost:5219
GET {{Azaion.Api_HostAddress}}/weatherforecast/
Accept: application/json
###
+48
View File
@@ -0,0 +1,48 @@
using Azaion.Common.Configs;
using Azaion.Common.Database;
using Azaion.Common.Requests;
using Azaion.Services;
using FluentValidation;
using Microsoft.Extensions.Options;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
builder.Services.Configure<ResourcesConfig>(builder.Configuration.GetSection(nameof(ResourcesConfig)));
builder.Services.AddScoped<IUserService, UserService>();
builder.Services.AddScoped<IResourcesService, ResourcesService>();
builder.Services.AddSingleton<IDbFactory, DbFactory>(sp => new DbFactory(sp.GetService<IOptions<ConnectionStrings>>()!.Value.AzaionDb));
builder.Services.AddValidatorsFromAssemblyContaining<RegisterUserValidator>();
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.UseHttpsRedirection();
app.MapPost("/register-user",
async (RegisterUserRequest registerUserRequest, IUserService userService, CancellationToken cancellationToken)
=> await userService.RegisterUser(registerUserRequest, cancellationToken));
app.MapPost("/resources/get",
async (GetResourceRequest getResourceRequest, IUserService userService, IResourcesService resourcesService, CancellationToken cancellationToken) =>
{
await userService.ValidateUser(getResourceRequest, cancellationToken);
var ms = new MemoryStream();
await resourcesService.GetEncryptedResource(getResourceRequest, ms, cancellationToken);
return ms;
});
app.MapPost("/resources",
async (UploadResourceRequest uploadResourceRequest, IResourcesService resourceService, CancellationToken cancellationToken)
=> await resourceService.SaveResource(uploadResourceRequest, cancellationToken));
app.Run();
+41
View File
@@ -0,0 +1,41 @@
{
"$schema": "http://json.schemastore.org/launchsettings.json",
"iisSettings": {
"windowsAuthentication": false,
"anonymousAuthentication": true,
"iisExpress": {
"applicationUrl": "http://localhost:10133",
"sslPort": 44330
}
},
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"launchUrl": "swagger",
"applicationUrl": "http://localhost:5219",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"launchUrl": "swagger",
"applicationUrl": "https://localhost:7139;http://localhost:5219",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"IIS Express": {
"commandName": "IISExpress",
"launchBrowser": true,
"launchUrl": "swagger",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}
+8
View File
@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}
+17
View File
@@ -0,0 +1,17 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
"ResourcesConfig": {
"ResourcesFolder": "Content",
"Resources": {
"AnnotatorDll": "Azaion.Annotator.dll",
"AIModelONNX": "azaion.onnx",
"AIModelRKNN": "azaion.rknn"
}
}
}
+14
View File
@@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FluentValidation" Version="11.10.0" />
<PackageReference Include="linq2db" Version="5.4.1" />
</ItemGroup>
</Project>
+18
View File
@@ -0,0 +1,18 @@
namespace Azaion.Common;
public class BusinessException(ExceptionEnum exEnum, string message) : Exception(message)
{
private ExceptionEnum ExceptionEnum { get; set; } = exEnum;
}
public enum ExceptionEnum
{
NoUserFound = 10,
UserExists = 20,
PasswordIncorrect = 30,
UserLengthIncorrect = 33,
WrongEmail = 35,
PasswordLengthIncorrect = 37,
HardwareIdMismatch = 40,
WrongResourceType = 50
}
@@ -0,0 +1,6 @@
namespace Azaion.Common.Configs;
public class ConnectionStrings
{
public string AzaionDb { get; set; } = null!;
}
+7
View File
@@ -0,0 +1,7 @@
namespace Azaion.Common.Configs;
public class ResourcesConfig
{
public string ResourcesFolder { get; set; } = null!;
public Dictionary<string, string> Resources { get; set; } = null!;
}
+10
View File
@@ -0,0 +1,10 @@
using Azaion.Common.Entities;
using LinqToDB;
using LinqToDB.Data;
namespace Azaion.Common.Database;
public class AzaionDb(DataOptions dataOptions) : DataConnection(dataOptions)
{
public ITable<User> Users => this.GetTable<User>();
}
@@ -0,0 +1,21 @@
using Azaion.Common.Entities;
using LinqToDB.Mapping;
namespace Azaion.Common.Database;
public static class AzaionDbSchemaHolder
{
public static readonly MappingSchema MappingSchema;
static AzaionDbSchemaHolder()
{
MappingSchema = new MappingSchema();
var builder = new FluentMappingBuilder(MappingSchema);
builder.Entity<User>()
.HasTableName("users")
.HasIdentity(x => x.Id);
builder.Build();
}
}
+48
View File
@@ -0,0 +1,48 @@
using System.Diagnostics;
using LinqToDB;
namespace Azaion.Common.Database;
public interface IDbFactory
{
Task<T> Run<T>(Func<AzaionDb, Task<T>> func);
Task Run(Func<AzaionDb, Task> func);
T Run<T>(Func<AzaionDb, T> func);
}
public class DbFactory : IDbFactory
{
private readonly DataOptions _dataOptions;
public DbFactory(string connectionString, bool useTracing = true, bool msSql = false)
{
if (string.IsNullOrEmpty(connectionString))
throw new ArgumentException("Empty connectionString", nameof(connectionString));
_dataOptions = new DataOptions()
.UsePostgreSQL(connectionString)
.UseMappingSchema(AzaionDbSchemaHolder.MappingSchema);
if (useTracing)
_ = _dataOptions.UseTracing(TraceLevel.Info, t => Console.WriteLine(t.SqlText));
}
public async Task<T> Run<T>(Func<AzaionDb, Task<T>> func)
{
await using var db = new AzaionDb(_dataOptions);
return await func(db);
}
public async Task Run(Func<AzaionDb, Task> func)
{
await using var db = new AzaionDb(_dataOptions);
await func(db);
}
public T Run<T>(Func<AzaionDb, T> func)
{
using var db = new AzaionDb(_dataOptions);
return func(db);
}
}
+8
View File
@@ -0,0 +1,8 @@
namespace Azaion.Common.Entities;
public enum ResourceEnum
{
AnnotatorDll = 10,
AIModelRKNN = 20,
AIModelONNX = 20,
}
+9
View File
@@ -0,0 +1,9 @@
namespace Azaion.Common;
public enum RoleEnum
{
Operator,
Validator,
CompanionPC,
Admin
}
+12
View File
@@ -0,0 +1,12 @@
namespace Azaion.Common.Entities;
public class User
{
public string Id { get; set; } = null!;
public string Username { get; set; } = null!;
public string PasswordHash { get; set; } = null!;
public string HardwareId { get; set; } = null!;
public RoleEnum Role { get; set; }
public string UniqueKey => $"Azaion#{Username}#{PasswordHash}#{HardwareId}";
}
@@ -0,0 +1,17 @@
using Azaion.Common.Entities;
namespace Azaion.Common.Requests;
public class GetResourceRequest
{
public string Username { get; set; } = null!;
public string Password { get; set; } = null!;
public string HardwareId { get; set; } = null!;
public ResourceEnum ResourceEnum { get; set; }
}
public class UploadResourceRequest
{
public ResourceEnum ResourceEnum { get; set; }
public Stream Data { get; set; } = null!;
}
@@ -0,0 +1,22 @@
using FluentValidation;
namespace Azaion.Common.Requests;
public class RegisterUserRequest
{
public string Email { get; set; } = null!;
public string Password { get; set; } = null!;
public RoleEnum Role { get; set; }
}
public class RegisterUserValidator : AbstractValidator<RegisterUserRequest>
{
public RegisterUserValidator()
{
RuleFor(r => r.Email)
.MinimumLength(8).WithErrorCode(ExceptionEnum.UserLengthIncorrect.ToString()).WithMessage("Email address should be at least 8 characters.")
.EmailAddress().WithErrorCode(ExceptionEnum.WrongEmail.ToString()).WithMessage("Email address is not valid.");
RuleFor(r => r.Password)
.MinimumLength(8).WithErrorCode(ExceptionEnum.PasswordLengthIncorrect.ToString()).WithMessage("Password should be at least 8 characters.");
} }
+19
View File
@@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Azaion.Common\Azaion.Common.csproj" />
</ItemGroup>
<ItemGroup>
<Reference Include="Microsoft.Extensions.Options">
<HintPath>C:\Program Files\dotnet\shared\Microsoft.AspNetCore.App\8.0.8\Microsoft.Extensions.Options.dll</HintPath>
</Reference>
</ItemGroup>
</Project>
+37
View File
@@ -0,0 +1,37 @@
using Azaion.Common;
using Azaion.Common.Configs;
using Azaion.Common.Entities;
using Azaion.Common.Requests;
using Microsoft.Extensions.Options;
namespace Azaion.Services;
public interface IResourcesService
{
Task GetEncryptedResource(GetResourceRequest request, Stream outputStream, CancellationToken cancellationToken = default);
Task SaveResource(UploadResourceRequest request, CancellationToken cancellationToken = default);
}
public class ResourcesService(IOptions<ResourcesConfig> resourcesConfig) : IResourcesService
{
public async Task GetEncryptedResource(GetResourceRequest request, Stream outputStream, CancellationToken cancellationToken = default)
{
var fileStream = new FileStream(GetResourcePath(request.ResourceEnum), FileMode.Open, FileAccess.Read);
var key = Security.MakeEncryptionKey(request.Username, request.Password);
await fileStream.Encrypt(outputStream, key, cancellationToken);
}
public async Task SaveResource(UploadResourceRequest request, CancellationToken cancellationToken = default)
{
await using var fileStream = new FileStream(GetResourcePath(request.ResourceEnum), FileMode.OpenOrCreate, FileAccess.ReadWrite);
await request.Data.CopyToAsync(fileStream, cancellationToken);
}
private string GetResourcePath(ResourceEnum resourceEnum)
{
var resource = resourcesConfig.Value.Resources.GetValueOrDefault(resourceEnum.ToString());
if (resource == null)
throw new BusinessException(ExceptionEnum.WrongResourceType, "Wrong resource type!");
return Path.Combine(resourcesConfig.Value.ResourcesFolder, resource);
}
}
+56
View File
@@ -0,0 +1,56 @@
using System.Security.Cryptography;
using System.Text;
namespace Azaion.Services;
public static class Security
{
private const int BUFFER_SIZE = 81920; // 80 KB buffer size
public static string ToHash(this string str) =>
Convert.ToBase64String(SHA384.HashData(Encoding.UTF8.GetBytes(str)));
public static string MakeEncryptionKey(string username, string password) =>
$"{username}-{password}---#%@AzaionKey@%#---";
public static async Task Encrypt(this Stream stream, Stream outputStream, string key, CancellationToken cancellationToken = default)
{
if (stream is { CanSeek: false }) throw new ArgumentNullException(nameof(stream));
if (key is not { Length: > 0 }) throw new ArgumentNullException(nameof(key));
using var aes = Aes.Create();
aes.Key = SHA256.HashData(Encoding.UTF8.GetBytes(key));
aes.GenerateIV();
using var encryptor = aes.CreateEncryptor(aes.Key, aes.IV);
await using var cs = new CryptoStream(outputStream, encryptor, CryptoStreamMode.Write);
// Prepend IV to the encrypted data
await outputStream.WriteAsync(aes.IV.AsMemory(0, aes.IV.Length), cancellationToken);
var buffer = new byte[BUFFER_SIZE];
int bytesRead;
while ((bytesRead = await stream.ReadAsync(buffer, cancellationToken)) > 0)
await cs.WriteAsync(buffer.AsMemory(0, bytesRead), cancellationToken);
}
public static async Task Decrypt(this Stream encryptedStream, Stream outputStream, string key, CancellationToken cancellationToken = default)
{
using var aes = Aes.Create();
aes.Key = SHA256.HashData(Encoding.UTF8.GetBytes(key));
// Read the IV from the start of the input stream
var iv = new byte[aes.BlockSize / 8];
_ = await encryptedStream.ReadAsync(iv, cancellationToken);
aes.IV = iv;
using var decryptor = aes.CreateDecryptor(aes.Key, aes.IV);
await using var cryptoStream = new CryptoStream(encryptedStream, decryptor, CryptoStreamMode.Read);
// Read and write in chunks
var buffer = new byte[BUFFER_SIZE];
int bytesRead;
while ((bytesRead = await cryptoStream.ReadAsync(buffer, cancellationToken)) > 0)
await outputStream.WriteAsync(buffer.AsMemory(0, bytesRead), cancellationToken);
}
}
+56
View File
@@ -0,0 +1,56 @@
using Azaion.Common;
using Azaion.Common.Database;
using Azaion.Common.Entities;
using Azaion.Common.Requests;
using LinqToDB;
namespace Azaion.Services;
public interface IUserService
{
Task RegisterUser(RegisterUserRequest request, CancellationToken cancellationToken = default);
Task<User?> ValidateUser(GetResourceRequest request, CancellationToken cancellationToken = default);
}
public class UserService(IDbFactory dbFactory) : IUserService
{
public async Task RegisterUser(RegisterUserRequest request, CancellationToken cancellationToken = default)
{
await dbFactory.Run(async db =>
{
var existingUser = await db.Users.FirstOrDefaultAsync(u => u.Username == request.Email, token: cancellationToken);
if (existingUser != null)
throw new BusinessException(ExceptionEnum.UserExists, "User already exists");
await db.InsertAsync(new User
{
Username = request.Email,
PasswordHash = request.Password.ToHash(),
Role = request.Role
}, token: cancellationToken);
});
}
public async Task<User?> ValidateUser(GetResourceRequest request, CancellationToken cancellationToken = default) =>
await dbFactory.Run(async db =>
{
var user = await db.Users.FirstOrDefaultAsync(x => x.Username == request.Username, token: cancellationToken);
if (user == null)
throw new BusinessException(ExceptionEnum.NoUserFound, "No user found");
if (request.Password.ToHash() != user.PasswordHash)
throw new BusinessException(ExceptionEnum.PasswordIncorrect, "Passwords do not match");
//If user's hardware Id is empty (usually on the first time login), then write down user
if (string.IsNullOrEmpty(user.HardwareId))
await db.Users.UpdateAsync(x => x.Username == request.Username, u => new User{HardwareId = request.HardwareId}, token: cancellationToken);
else
{
//But if hardware Id exists, it should match with request
if (user.HardwareId != request.HardwareId)
throw new BusinessException(ExceptionEnum.HardwareIdMismatch, "Hardware id mismatch");
}
return user;
});
}
+23
View File
@@ -0,0 +1,23 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" Version="6.12.2" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Azaion.Services\Azaion.Services.csproj" />
</ItemGroup>
</Project>
+41
View File
@@ -0,0 +1,41 @@
using System.Text;
using Azaion.Services;
using FluentAssertions;
using Xunit;
namespace Azaion.Test;
public class SecurityTest
{
[Fact]
public async Task EncryptDecryptTest()
{
var testString = "Hello World Test dfvjkhsdbfvkljh sabdljsdafv asdv";
var username = "user@azaion.com";
var password = "testpw";
var key = Security.MakeEncryptionKey(username, password);
await using var encryptedStream = new MemoryStream();
await StringToStream(testString).Encrypt(encryptedStream, key);
await using var decryptedStream = new MemoryStream();
await encryptedStream.Decrypt(decryptedStream, key);
var str = StreamToString(decryptedStream);
str.Should().Be(testString);
}
private static string StreamToString(Stream stream)
{
stream.Position = 0;
using var reader = new StreamReader(stream, Encoding.UTF8);
return reader.ReadToEnd();
}
private static Stream StringToStream(string src)
{
var byteArray = Encoding.UTF8.GetBytes(src);
return new MemoryStream(byteArray);
}
}