diff --git a/Azaion.Api/Program.cs b/Azaion.Api/Program.cs index 60cfaff..d538757 100644 --- a/Azaion.Api/Program.cs +++ b/Azaion.Api/Program.cs @@ -9,8 +9,10 @@ using FluentValidation; using FluentValidation.AspNetCore; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; using Microsoft.IdentityModel.Tokens; using Microsoft.OpenApi.Models; +using Swashbuckle.AspNetCore.Swagger; var builder = WebApplication.CreateBuilder(args); builder.WebHost.ConfigureKestrel(o => o.Limits.MaxRequestBodySize = 209715200); //increase upload limit up to 200mb @@ -97,25 +99,28 @@ app.MapPost("/login", return Results.Ok(new { Token = authService.CreateToken(user)}); }); -app.MapPost("/register-user", +app.MapPost("/users", async (RegisterUserRequest registerUserRequest, IUserService userService, CancellationToken cancellationToken) => await userService.RegisterUser(registerUserRequest, cancellationToken)) - .RequireAuthorization(apiAdminPolicy); + .RequireAuthorization(apiAdminPolicy) + .WithDescription("Creates a new user"); + +app.MapGet("/users", + async (string searchEmail, RoleEnum? searchRole, IUserService userService, CancellationToken cancellationToken) + => await userService.GetUsers(searchEmail, searchRole, cancellationToken)) + .RequireAuthorization(apiAdminPolicy) + .WithDescription("Lists all users"); app.MapPost("/resources", async (ResourceEnum resourceEnum, IFormFile data, IResourcesService resourceService, CancellationToken cancellationToken) => await resourceService.SaveResource(resourceEnum, data, cancellationToken)) .Accepts("multipart/form-data") .RequireAuthorization(apiAdminPolicy) - .DisableAntiforgery(); + .DisableAntiforgery() + .WithDescription("Uploads / Replace existing resource by type"); -app.MapPost("/resources/reset-hardware", - async (string email, IUserService userService, CancellationToken cancellationToken) - => await userService.UpdateHardwareId(email, null!, cancellationToken)); - - -app.MapPost("/resources/get", - async (GetResourceRequest request, IAuthService authService, IUserService userService, IResourcesService resourcesService, CancellationToken cancellationToken) => +app.MapGet("/resources", + async ([FromBody]GetResourceRequest request, IAuthService authService, IUserService userService, IResourcesService resourcesService, CancellationToken cancellationToken) => { var user = authService.CurrentUser; if (user == null) @@ -128,13 +133,18 @@ app.MapPost("/resources/get", } if (user.HardwareId != request.HardwareId) - throw new BusinessException(ExceptionEnum.HardwareIdMismatch, "Hardware mismatch! You are not authorized to access this resource from this hardware."); + throw new BusinessException(ExceptionEnum.HardwareIdMismatch); var ms = new MemoryStream(); - var key = Security.MakeEncryptionKey(user.Email, request.Password); + var key = Security.MakeEncryptionKey(user.Email, request.Password, request.HardwareId); var filename = await resourcesService.GetEncryptedResource(request.ResourceEnum, key, ms, cancellationToken); return Results.File(ms, "application/octet-stream", filename); - }).RequireAuthorization(); + }).RequireAuthorization() + .WithDescription("Gets encrypted by users Password and HardwareId resources "); + +app.MapPut("/resources/reset-hardware", + async (string email, IUserService userService, CancellationToken cancellationToken) + => await userService.UpdateHardwareId(email, null!, cancellationToken)); app.Run(); diff --git a/Azaion.Common/BusinessException.cs b/Azaion.Common/BusinessException.cs index 47eb561..6a3b0eb 100644 --- a/Azaion.Common/BusinessException.cs +++ b/Azaion.Common/BusinessException.cs @@ -1,20 +1,49 @@ -namespace Azaion.Common; +using System.ComponentModel; +using Azaion.Common.Extensions; -public class BusinessException(ExceptionEnum exEnum, string message) : Exception(message) +namespace Azaion.Common; + +public class BusinessException(ExceptionEnum exEnum) : Exception(GetMessage(exEnum)) { + private static readonly Dictionary ExceptionDescriptions; + + static BusinessException() + { + ExceptionDescriptions = EnumExtensions.GetDescriptions(); + } + private ExceptionEnum ExceptionEnum { get; set; } = exEnum; + + public static string GetMessage(ExceptionEnum exEnum) => ExceptionDescriptions.GetValueOrDefault(exEnum) ?? exEnum.ToString(); } public enum ExceptionEnum { - NoUserFound = 10, - NoUser = 15, - UserExists = 20, - PasswordIncorrect = 30, - UserLengthIncorrect = 33, - WrongEmail = 35, - PasswordLengthIncorrect = 37, + [Description("No such email found.")] + NoEmailFound = 10, + + [Description("Email already exists.")] + EmailExists = 20, + + [Description("Passwords do not match.")] + WrongPassword = 30, + + [Description("Password should be at least 8 characters.")] + PasswordLengthIncorrect = 32, + + EmailLengthIncorrect = 35, + + WrongEmail = 37, + + [Description("Hardware mismatch! You are not authorized to access this resource from this hardware.")] HardwareIdMismatch = 40, + + [Description("Hardware Id should be at least 8 characters.")] + HardwareIdLength = 45, + + [Description("Wrong resource type.")] WrongResourceType = 50, - NoFile = 60 + + [Description("No file provided.")] + NoFileProvided = 60, } \ No newline at end of file diff --git a/Azaion.Common/Database/AzaionDbShemaHolder.cs b/Azaion.Common/Database/AzaionDbShemaHolder.cs index fc73975..afa5d83 100644 --- a/Azaion.Common/Database/AzaionDbShemaHolder.cs +++ b/Azaion.Common/Database/AzaionDbShemaHolder.cs @@ -1,4 +1,5 @@ using Azaion.Common.Entities; +using Azaion.Common.Extensions; using LinqToDB; using LinqToDB.Mapping; diff --git a/Azaion.Common/Extensions/EnumExtensions.cs b/Azaion.Common/Extensions/EnumExtensions.cs new file mode 100644 index 0000000..f9b94a4 --- /dev/null +++ b/Azaion.Common/Extensions/EnumExtensions.cs @@ -0,0 +1,54 @@ +using System.ComponentModel; +using System.Reflection; + +namespace Azaion.Common.Extensions; + +/// Enum extensions +public static class EnumExtensions +{ + /// Get all enums with descriptions + public static Dictionary GetDescriptions() where T : Enum => + Enum.GetValues(typeof(T)).Cast() + .ToDictionary(x => x, x => x.GetEnumAttrib()?.Description ?? x.ToString()); + + /// + /// Get the Description from the DescriptionAttribute. + /// + /// + /// + public static string GetDescription(this Enum enumValue) + { + return enumValue.GetType() + .GetMember(enumValue.ToString()) + .First() + .GetCustomAttribute()? + .Description ?? enumValue.ToString(); + } + + /// Get attribute for enum's member, usually is used for getting Description attribute + public static TAttrib GetEnumAttrib(this T value) where T: Enum + { + var field = value.GetType().GetField(value.ToString()); + if (field == null) + return default; + + return field.GetCustomAttributes(typeof(TAttrib), false) + .Cast() + .FirstOrDefault(); + } + + /// + /// Get default value for enum + /// + /// + /// + public static TEnum GetDefaultValue() where TEnum : struct + { + var t = typeof(TEnum); + var attributes = (DefaultValueAttribute[])t.GetCustomAttributes(typeof(DefaultValueAttribute), false); + if (attributes is { Length: > 0 }) + return (TEnum)attributes[0].Value!; + + return default; + } +} \ No newline at end of file diff --git a/Azaion.Common/Extensions/QueryableExtensions.cs b/Azaion.Common/Extensions/QueryableExtensions.cs new file mode 100644 index 0000000..f9cabab --- /dev/null +++ b/Azaion.Common/Extensions/QueryableExtensions.cs @@ -0,0 +1,27 @@ +using System.Linq.Expressions; + +namespace Azaion.Common.Extensions; + +public static class QueryableExtensions +{ + /// + /// Adds Where true predicate only if result of condition is true. + /// If false predicate provided, uses it in case of false result + /// Useful for filters, when filters should be applied only when it was set (not NULL) + /// + public static IQueryable WhereIf(this IQueryable query, bool? condition, + Expression> truePredicate, + Expression>? falsePredicate = null) + { + if (!condition.HasValue) + return query; + + if (condition.Value) + return query.Where(truePredicate); + + return falsePredicate != null + ? query.Where(falsePredicate) + : query; + } + +} \ No newline at end of file diff --git a/Azaion.Common/StringExtensions.cs b/Azaion.Common/Extensions/StringExtensions.cs similarity index 94% rename from Azaion.Common/StringExtensions.cs rename to Azaion.Common/Extensions/StringExtensions.cs index b2fba24..e9ce086 100644 --- a/Azaion.Common/StringExtensions.cs +++ b/Azaion.Common/Extensions/StringExtensions.cs @@ -1,6 +1,6 @@ using System.Text; -namespace Azaion.Common; +namespace Azaion.Common.Extensions; public static class StringExtensions { diff --git a/Azaion.Common/Requests/GetResourceRequest.cs b/Azaion.Common/Requests/GetResourceRequest.cs index 8af3440..c61903c 100644 --- a/Azaion.Common/Requests/GetResourceRequest.cs +++ b/Azaion.Common/Requests/GetResourceRequest.cs @@ -15,9 +15,18 @@ public class GetResourceRequestValidator : AbstractValidator public GetResourceRequestValidator() { RuleFor(r => r.Password) - .MinimumLength(8).WithErrorCode(ExceptionEnum.PasswordLengthIncorrect.ToString()).WithMessage("Password should be at least 8 characters."); + .MinimumLength(8) + .WithErrorCode(ExceptionEnum.PasswordLengthIncorrect.ToString()) + .WithMessage(_ => BusinessException.GetMessage(ExceptionEnum.PasswordLengthIncorrect)); RuleFor(r => r.HardwareId) - .NotEmpty().WithErrorCode(ExceptionEnum.HardwareIdMismatch.ToString()).WithMessage("Hardware Id should be not empty."); + .MinimumLength(8) + .WithErrorCode(ExceptionEnum.HardwareIdLength.ToString()) + .WithMessage(_ => BusinessException.GetMessage(ExceptionEnum.HardwareIdLength)); + + RuleFor(r => r.ResourceEnum) + .NotEqual(ResourceEnum.None) + .WithErrorCode(ExceptionEnum.WrongResourceType.ToString()) + .WithMessage(_ => BusinessException.GetMessage(ExceptionEnum.WrongResourceType)); } } \ No newline at end of file diff --git a/Azaion.Common/Requests/RegisterUserRequest.cs b/Azaion.Common/Requests/RegisterUserRequest.cs index 060acf8..0760225 100644 --- a/Azaion.Common/Requests/RegisterUserRequest.cs +++ b/Azaion.Common/Requests/RegisterUserRequest.cs @@ -15,7 +15,7 @@ public class RegisterUserValidator : AbstractValidator public RegisterUserValidator() { RuleFor(r => r.Email) - .MinimumLength(8).WithErrorCode(ExceptionEnum.UserLengthIncorrect.ToString()).WithMessage("Email address should be at least 8 characters.") + .MinimumLength(8).WithErrorCode(ExceptionEnum.EmailLengthIncorrect.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) diff --git a/Azaion.Services/ResourcesService.cs b/Azaion.Services/ResourcesService.cs index 6aef165..f33fbb7 100644 --- a/Azaion.Services/ResourcesService.cs +++ b/Azaion.Services/ResourcesService.cs @@ -28,7 +28,7 @@ public class ResourcesService(IOptions resourcesConfig) : IReso public async Task SaveResource(ResourceEnum resourceEnum, IFormFile data, CancellationToken cancellationToken = default) { if (data == null) - throw new BusinessException(ExceptionEnum.NoFile, "No file provided!"); + throw new BusinessException(ExceptionEnum.NoFileProvided); if (!Directory.Exists(resourcesConfig.Value.ResourcesFolder)) Directory.CreateDirectory(resourcesConfig.Value.ResourcesFolder); @@ -40,7 +40,7 @@ public class ResourcesService(IOptions resourcesConfig) : IReso { var resource = resourcesConfig.Value.Resources.GetValueOrDefault(resourceEnum.ToString()); if (resource == null) - throw new BusinessException(ExceptionEnum.WrongResourceType, "Wrong resource type!"); + throw new BusinessException(ExceptionEnum.WrongResourceType); return Path.Combine(resourcesConfig.Value.ResourcesFolder, resource); } } \ No newline at end of file diff --git a/Azaion.Services/Security.cs b/Azaion.Services/Security.cs index b10b935..bd16b8d 100644 --- a/Azaion.Services/Security.cs +++ b/Azaion.Services/Security.cs @@ -10,8 +10,8 @@ public static class Security 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 string MakeEncryptionKey(string username, string password, string hardwareId) => + $"{username}-{password}-{hardwareId}-#%@AzaionKey@%#---"; public static async Task EncryptTo(this Stream stream, Stream toStream, string key, CancellationToken cancellationToken = default) { diff --git a/Azaion.Services/UserService.cs b/Azaion.Services/UserService.cs index ee994af..8e3350d 100644 --- a/Azaion.Services/UserService.cs +++ b/Azaion.Services/UserService.cs @@ -1,6 +1,7 @@ using Azaion.Common; using Azaion.Common.Database; using Azaion.Common.Entities; +using Azaion.Common.Extensions; using Azaion.Common.Requests; using LinqToDB; @@ -11,6 +12,7 @@ public interface IUserService Task RegisterUser(RegisterUserRequest request, CancellationToken cancellationToken = default); Task ValidateUser(LoginRequest request, string? hardwareId = null, CancellationToken cancellationToken = default); Task UpdateHardwareId(string email, string hardwareId, CancellationToken cancellationToken = default); + Task> GetUsers(string searchEmail, RoleEnum? searchRole, CancellationToken cancellationToken); } public class UserService(IDbFactory dbFactory) : IUserService @@ -21,7 +23,7 @@ public class UserService(IDbFactory dbFactory) : IUserService { var existingUser = await db.Users.FirstOrDefaultAsync(u => u.Email == request.Email, token: cancellationToken); if (existingUser != null) - throw new BusinessException(ExceptionEnum.UserExists, "User already exists"); + throw new BusinessException(ExceptionEnum.EmailExists); await db.InsertAsync(new User { @@ -38,21 +40,30 @@ public class UserService(IDbFactory dbFactory) : IUserService { var user = await db.Users.FirstOrDefaultAsync(x => x.Email == request.Email, token: cancellationToken); if (user == null) - throw new BusinessException(ExceptionEnum.NoUserFound, "No user found"); + throw new BusinessException(ExceptionEnum.NoEmailFound); if (request.Password.ToHash() != user.PasswordHash) - throw new BusinessException(ExceptionEnum.PasswordIncorrect, "Passwords do not match"); + throw new BusinessException(ExceptionEnum.WrongPassword); if (user.Role == RoleEnum.ApiAdmin) return user; // For Non-API admins hardwareId should match if it was already set if (user.HardwareId != null && user.HardwareId != hardwareId) - throw new BusinessException(ExceptionEnum.HardwareIdMismatch, "Hardware id mismatch"); + throw new BusinessException(ExceptionEnum.HardwareIdMismatch); return user; }); public async Task UpdateHardwareId(string email, string hardwareId, CancellationToken cancellationToken = default) => await dbFactory.RunAdmin(async db => await db.Users.UpdateAsync(x => x.Email == email, u => new User { HardwareId = hardwareId}, token: cancellationToken)); + + public async Task> GetUsers(string searchEmail, RoleEnum? searchRole, CancellationToken cancellationToken) => + await dbFactory.Run(async db => + await db.Users + .WhereIf(!string.IsNullOrEmpty(searchEmail), + u => u.Email.ToLower().Contains(searchEmail.ToLower())) + .WhereIf(searchRole != null, + u => u.Role == searchRole) + .ToListAsync(token: cancellationToken)); } diff --git a/Azaion.Test/SecurityTest.cs b/Azaion.Test/SecurityTest.cs index 83e02ad..56fdafa 100644 --- a/Azaion.Test/SecurityTest.cs +++ b/Azaion.Test/SecurityTest.cs @@ -15,7 +15,9 @@ public class SecurityTest var testString = "Hello World Test dfvjkhsdbfvkljh sabdljsdafv asdv"; var username = "user@azaion.com"; var password = "testpw"; - var key = Security.MakeEncryptionKey(username, password); + var hardwareId = "test_hardware_id"; + + var key = Security.MakeEncryptionKey(username, password, hardwareId); var encryptedStream = new MemoryStream(); await StringToStream(testString).EncryptTo(encryptedStream, key); @@ -34,7 +36,9 @@ public class SecurityTest { var username = "user@azaion.com"; var password = "testpw"; - var key = Security.MakeEncryptionKey(username, password); + var hardwareId = "test_hardware_id"; + + var key = Security.MakeEncryptionKey(username, password, hardwareId); var largeFilePath = "large.txt"; var largeFileDecryptedPath = "large_decrypted.txt";