diff --git a/Azaion.Api/Azaion.Api.csproj b/Azaion.Api/Azaion.Api.csproj index 963b42e..faeea4e 100644 --- a/Azaion.Api/Azaion.Api.csproj +++ b/Azaion.Api/Azaion.Api.csproj @@ -8,6 +8,7 @@ + diff --git a/Azaion.Api/Program.cs b/Azaion.Api/Program.cs index c9dd83f..01a91f3 100644 --- a/Azaion.Api/Program.cs +++ b/Azaion.Api/Program.cs @@ -1,11 +1,41 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Text; +using Azaion.Common; using Azaion.Common.Configs; using Azaion.Common.Database; +using Azaion.Common.Entities; using Azaion.Common.Requests; using Azaion.Services; using FluentValidation; +using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.Extensions.Options; +using Microsoft.IdentityModel.Tokens; var builder = WebApplication.CreateBuilder(args); +builder.Configuration.AddEnvironmentVariables(); + +var jwtConfig = builder.Configuration.GetSection(nameof(JwtConfig)).Get(); +if (jwtConfig == null) + throw new Exception("Missing configuration section: JwtConfig"); +var signingKey = new SymmetricSecurityKey(Encoding.ASCII.GetBytes(jwtConfig.Secret)); + +builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddJwtBearer(o => + { + o.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidateAudience = true, + ValidateLifetime = true, + ValidateIssuerSigningKey = true, + ValidIssuer = jwtConfig.Issuer, + ValidAudience = jwtConfig.Audience, + IssuerSigningKey = signingKey + }; + }); +builder.Services.AddAuthorization(); +builder.Services.AddHttpContextAccessor(); builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(); @@ -27,22 +57,57 @@ if (app.Environment.IsDevelopment()) } app.UseHttpsRedirection(); +app.UseAuthentication(); +app.UseAuthorization(); + +app.MapPost("/login", + async (string username, string password, IUserService userService, CancellationToken cancellationToken) => + { + var user = await userService.ValidateUser(username, password); + + var tokenHandler = new JwtSecurityTokenHandler(); + var tokenDescriptor = new SecurityTokenDescriptor + { + Subject = new ClaimsIdentity([ + new Claim(ClaimTypes.NameIdentifier, user.Id), + new Claim(ClaimTypes.Name, user.Email), + new Claim(ClaimTypes.Role, user.Role.ToString()), + new Claim(Constants.HARDWARE_ID, user.HardwareId) + ]), + Expires = DateTime.UtcNow.AddHours(2), + Issuer = jwtConfig.Issuer, + Audience = jwtConfig.Audience, + SigningCredentials = new SigningCredentials(signingKey, SecurityAlgorithms.HmacSha256Signature) + }; + + var token = tokenHandler.CreateToken(tokenDescriptor); + var tokenString = tokenHandler.WriteToken(token); + + return Results.Ok(new { Token = tokenString }); + }); 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; - }); + => await userService.RegisterUser(registerUserRequest, cancellationToken)) + .RequireAuthorization(p => p.RequireRole(RoleEnum.ApiAdmin.ToString())); app.MapPost("/resources", async (UploadResourceRequest uploadResourceRequest, IResourcesService resourceService, CancellationToken cancellationToken) - => await resourceService.SaveResource(uploadResourceRequest, cancellationToken)); + => await resourceService.SaveResource(uploadResourceRequest, cancellationToken)) + .RequireAuthorization(p => p.RequireRole(RoleEnum.ApiAdmin.ToString())); + +app.MapPost("/resources/get", + async (GetResourceRequest request, IUserService userService, IResourcesService resourcesService, CancellationToken cancellationToken) => + { + var user = userService.CurrentUser; + if (user == null) + throw new BusinessException(ExceptionEnum.NoUser, "No current user"); + if (string.IsNullOrEmpty(user.HardwareId)) + await userService.UpdateHardwareId(user.Email, request.HardwareId); + var ms = new MemoryStream(); + var key = Security.MakeEncryptionKey(user.Email, request.Password); + await resourcesService.GetEncryptedResource(request.ResourceEnum, key, ms, cancellationToken); + return ms; + }).RequireAuthorization(); app.Run(); \ No newline at end of file diff --git a/Azaion.Common/BusinessException.cs b/Azaion.Common/BusinessException.cs index 9146cc5..3c32844 100644 --- a/Azaion.Common/BusinessException.cs +++ b/Azaion.Common/BusinessException.cs @@ -8,6 +8,7 @@ public class BusinessException(ExceptionEnum exEnum, string message) : Exception public enum ExceptionEnum { NoUserFound = 10, + NoUser = 15, UserExists = 20, PasswordIncorrect = 30, UserLengthIncorrect = 33, diff --git a/Azaion.Common/Configs/Constants.cs b/Azaion.Common/Configs/Constants.cs new file mode 100644 index 0000000..f050783 --- /dev/null +++ b/Azaion.Common/Configs/Constants.cs @@ -0,0 +1,6 @@ +namespace Azaion.Common.Configs; + +public class Constants +{ + public const string HARDWARE_ID = nameof(HARDWARE_ID); +} \ No newline at end of file diff --git a/Azaion.Common/Configs/JwtConfig.cs b/Azaion.Common/Configs/JwtConfig.cs new file mode 100644 index 0000000..635d70f --- /dev/null +++ b/Azaion.Common/Configs/JwtConfig.cs @@ -0,0 +1,8 @@ +namespace Azaion.Common.Configs; + +public class JwtConfig +{ + public string Issuer { get; set; } = null!; + public string Audience { get; set; } = null!; + public string Secret { get; set; } +} \ No newline at end of file diff --git a/Azaion.Common/Entities/RoleEnum.cs b/Azaion.Common/Entities/RoleEnum.cs index 07bb839..7c0f4ab 100644 --- a/Azaion.Common/Entities/RoleEnum.cs +++ b/Azaion.Common/Entities/RoleEnum.cs @@ -1,9 +1,10 @@ -namespace Azaion.Common; +namespace Azaion.Common.Entities; public enum RoleEnum { Operator, Validator, CompanionPC, - Admin + Admin, + ApiAdmin } diff --git a/Azaion.Common/Entities/User.cs b/Azaion.Common/Entities/User.cs index 335e799..c8b7a96 100644 --- a/Azaion.Common/Entities/User.cs +++ b/Azaion.Common/Entities/User.cs @@ -3,10 +3,8 @@ public class User { public string Id { get; set; } = null!; - public string Username { get; set; } = null!; + public string Email { 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}"; } \ No newline at end of file diff --git a/Azaion.Common/Requests/GetResourceRequest.cs b/Azaion.Common/Requests/GetResourceRequest.cs index 06e3052..6591ad6 100644 --- a/Azaion.Common/Requests/GetResourceRequest.cs +++ b/Azaion.Common/Requests/GetResourceRequest.cs @@ -4,9 +4,8 @@ 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 string Password { get; set; } = null!; + public string HardwareId { get; set; } = null!; public ResourceEnum ResourceEnum { get; set; } } diff --git a/Azaion.Common/Requests/RegisterUserRequest.cs b/Azaion.Common/Requests/RegisterUserRequest.cs index ad0508c..060acf8 100644 --- a/Azaion.Common/Requests/RegisterUserRequest.cs +++ b/Azaion.Common/Requests/RegisterUserRequest.cs @@ -1,3 +1,4 @@ +using Azaion.Common.Entities; using FluentValidation; namespace Azaion.Common.Requests; diff --git a/Azaion.Services/Azaion.Services.csproj b/Azaion.Services/Azaion.Services.csproj index 7feaa28..6429d28 100644 --- a/Azaion.Services/Azaion.Services.csproj +++ b/Azaion.Services/Azaion.Services.csproj @@ -11,6 +11,9 @@ + + C:\Program Files\dotnet\shared\Microsoft.AspNetCore.App\8.0.8\Microsoft.AspNetCore.Http.Abstractions.dll + C:\Program Files\dotnet\shared\Microsoft.AspNetCore.App\8.0.8\Microsoft.Extensions.Options.dll diff --git a/Azaion.Services/ResourcesService.cs b/Azaion.Services/ResourcesService.cs index ea63ba4..2ecb6e6 100644 --- a/Azaion.Services/ResourcesService.cs +++ b/Azaion.Services/ResourcesService.cs @@ -8,16 +8,15 @@ namespace Azaion.Services; public interface IResourcesService { - Task GetEncryptedResource(GetResourceRequest request, Stream outputStream, CancellationToken cancellationToken = default); + Task GetEncryptedResource(ResourceEnum resource, string key, Stream outputStream, CancellationToken cancellationToken = default); Task SaveResource(UploadResourceRequest request, CancellationToken cancellationToken = default); } public class ResourcesService(IOptions resourcesConfig) : IResourcesService { - public async Task GetEncryptedResource(GetResourceRequest request, Stream outputStream, CancellationToken cancellationToken = default) + public async Task GetEncryptedResource(ResourceEnum resource, string key, Stream outputStream, CancellationToken cancellationToken = default) { - var fileStream = new FileStream(GetResourcePath(request.ResourceEnum), FileMode.Open, FileAccess.Read); - var key = Security.MakeEncryptionKey(request.Username, request.Password); + var fileStream = new FileStream(GetResourcePath(resource), FileMode.Open, FileAccess.Read); await fileStream.EncryptTo(outputStream, key, cancellationToken); } diff --git a/Azaion.Services/UserService.cs b/Azaion.Services/UserService.cs index 3038eae..045e63c 100644 --- a/Azaion.Services/UserService.cs +++ b/Azaion.Services/UserService.cs @@ -1,56 +1,80 @@ -using Azaion.Common; +using System.Security.Claims; +using Azaion.Common; +using Azaion.Common.Configs; using Azaion.Common.Database; using Azaion.Common.Entities; using Azaion.Common.Requests; using LinqToDB; +using Microsoft.AspNetCore.Http; namespace Azaion.Services; public interface IUserService { + User? CurrentUser { get; } Task RegisterUser(RegisterUserRequest request, CancellationToken cancellationToken = default); - Task ValidateUser(GetResourceRequest request, CancellationToken cancellationToken = default); + Task ValidateUser(string username, string password, string? hardwareId = null, CancellationToken cancellationToken = default); + Task UpdateHardwareId(string username, string hardwareId, CancellationToken cancellationToken = default); } -public class UserService(IDbFactory dbFactory) : IUserService +public class UserService(IDbFactory dbFactory, IHttpContextAccessor httpContextAccessor) : IUserService { + public User? CurrentUser + { + get + { + var claims = httpContextAccessor.HttpContext?.User.Claims.ToDictionary(x => x.Type); + if (claims == null) + return null; + + Enum.TryParse(claims[ClaimTypes.Role].Value, out RoleEnum role); + return new User + { + Id = claims[ClaimTypes.NameIdentifier].Value, + Email = claims[ClaimTypes.Name].Value, + Role = role, + HardwareId = claims[Constants.HARDWARE_ID].Value, + }; + } + } + 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); + var existingUser = await db.Users.FirstOrDefaultAsync(u => u.Email == request.Email, token: cancellationToken); if (existingUser != null) throw new BusinessException(ExceptionEnum.UserExists, "User already exists"); await db.InsertAsync(new User { - Username = request.Email, + Email = request.Email, PasswordHash = request.Password.ToHash(), Role = request.Role }, token: cancellationToken); }); } - public async Task ValidateUser(GetResourceRequest request, CancellationToken cancellationToken = default) => + public async Task ValidateUser(string username, string password, string? hardwareId = null, CancellationToken cancellationToken = default) => await dbFactory.Run(async db => { - var user = await db.Users.FirstOrDefaultAsync(x => x.Username == request.Username, token: cancellationToken); + var user = await db.Users.FirstOrDefaultAsync(x => x.Email == username, token: cancellationToken); if (user == null) throw new BusinessException(ExceptionEnum.NoUserFound, "No user found"); - if (request.Password.ToHash() != user.PasswordHash) + if (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"); - } + 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"); return user; }); + + public async Task UpdateHardwareId(string username, string hardwareId, CancellationToken cancellationToken = default) => + await dbFactory.Run(async db => + await db.Users.UpdateAsync(x => x.Email == username, u => new User { HardwareId = hardwareId}, token: cancellationToken)); }