diff --git a/.gitignore b/.gitignore index 8b9cbb4..e17a24d 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,5 @@ obj .vs *.DotSettings* *.user -log* \ No newline at end of file +log* +*.cmd \ No newline at end of file diff --git a/Azaion.Api/Program.cs b/Azaion.Api/Program.cs index 01a91f3..869cde8 100644 --- a/Azaion.Api/Program.cs +++ b/Azaion.Api/Program.cs @@ -1,5 +1,3 @@ -using System.IdentityModel.Tokens.Jwt; -using System.Security.Claims; using System.Text; using Azaion.Common; using Azaion.Common.Configs; @@ -9,11 +7,10 @@ using Azaion.Common.Requests; using Azaion.Services; using FluentValidation; using Microsoft.AspNetCore.Authentication.JwtBearer; -using Microsoft.Extensions.Options; using Microsoft.IdentityModel.Tokens; +using Microsoft.OpenApi.Models; var builder = WebApplication.CreateBuilder(args); -builder.Configuration.AddEnvironmentVariables(); var jwtConfig = builder.Configuration.GetSection(nameof(JwtConfig)).Get(); if (jwtConfig == null) @@ -38,12 +35,41 @@ builder.Services.AddAuthorization(); builder.Services.AddHttpContextAccessor(); builder.Services.AddEndpointsApiExplorer(); -builder.Services.AddSwaggerGen(); +builder.Services.AddSwaggerGen(c => +{ + c.SwaggerDoc("v1", new OpenApiInfo {Title = "Azaion.API", Version = "v1"}); + c.CustomSchemaIds(type => type.ToString()); + var jwtSecurityScheme = new OpenApiSecurityScheme + { + Scheme = "bearer", + BearerFormat = "JWT", + Name = "JWT Authentication", + In = ParameterLocation.Header, + Type = SecuritySchemeType.Http, + Description = "Put **_ONLY_** your JWT Bearer token on textbox below!", + + Reference = new OpenApiReference + { + Id = JwtBearerDefaults.AuthenticationScheme, + Type = ReferenceType.SecurityScheme + } + }; + + c.AddSecurityDefinition(jwtSecurityScheme.Reference.Id, jwtSecurityScheme); + + c.AddSecurityRequirement(new OpenApiSecurityRequirement + { + { jwtSecurityScheme, Array.Empty() } + }); +}); builder.Services.Configure(builder.Configuration.GetSection(nameof(ResourcesConfig))); +builder.Services.Configure(builder.Configuration.GetSection(nameof(JwtConfig))); +builder.Services.Configure(builder.Configuration.GetSection(nameof(ConnectionStrings))); + builder.Services.AddScoped(); builder.Services.AddScoped(); - -builder.Services.AddSingleton(sp => new DbFactory(sp.GetService>()!.Value.AzaionDb)); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddValidatorsFromAssemblyContaining(); @@ -61,29 +87,10 @@ app.UseAuthentication(); app.UseAuthorization(); app.MapPost("/login", - async (string username, string password, IUserService userService, CancellationToken cancellationToken) => + async (string username, string password, IUserService userService, IAuthService authService, 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 }); + var user = await userService.ValidateUser(username, password, cancellationToken: cancellationToken); + return Results.Ok(new { Token = authService.CreateToken(user)}); }); app.MapPost("/register-user", @@ -97,13 +104,15 @@ app.MapPost("/resources", .RequireAuthorization(p => p.RequireRole(RoleEnum.ApiAdmin.ToString())); app.MapPost("/resources/get", - async (GetResourceRequest request, IUserService userService, IResourcesService resourcesService, CancellationToken cancellationToken) => + async (GetResourceRequest request, IAuthService authService, IUserService userService, IResourcesService resourcesService, CancellationToken cancellationToken) => { - var user = userService.CurrentUser; - if (user == null) - throw new BusinessException(ExceptionEnum.NoUser, "No current user"); + var user = authService.CurrentUser; + if (user?.HardwareId != request.HardwareId) + throw new BusinessException(ExceptionEnum.HardwareIdMismatch, "Hardware mismatch! You are not authorized to access this resource from this hardware."); + 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); diff --git a/Azaion.Api/appsettings.Development.json b/Azaion.Api/appsettings.Development.json index 0c208ae..76727db 100644 --- a/Azaion.Api/appsettings.Development.json +++ b/Azaion.Api/appsettings.Development.json @@ -4,5 +4,9 @@ "Default": "Information", "Microsoft.AspNetCore": "Warning" } + }, + "ConnectionStrings": { + "AzaionDb": "Host=localhost;Database=azaion;Username=azaion_reader;Password=Az@1on_re@d!only@$Az;", + "AzaionDbAdmin": "Host=localhost;Database=azaion;Username=azaion_admin;Password=Az@1on_admin$$@r;" } } diff --git a/Azaion.Api/appsettings.json b/Azaion.Api/appsettings.json index 4843f7d..28db6ab 100644 --- a/Azaion.Api/appsettings.json +++ b/Azaion.Api/appsettings.json @@ -13,5 +13,10 @@ "AIModelONNX": "azaion.onnx", "AIModelRKNN": "azaion.rknn" } + }, + "JwtConfig": { + "Issuer": "AzaionApi", + "Audience": "Annotators/OrangePi/Admins", + "TokenLifetimeHours": 2.5 } } diff --git a/Azaion.Common/Azaion.Common.csproj b/Azaion.Common/Azaion.Common.csproj index 1b8e386..d0dcc4e 100644 --- a/Azaion.Common/Azaion.Common.csproj +++ b/Azaion.Common/Azaion.Common.csproj @@ -9,6 +9,13 @@ + + + + + + C:\Program Files\dotnet\shared\Microsoft.AspNetCore.App\8.0.8\Microsoft.Extensions.Options.dll + diff --git a/Azaion.Common/Configs/ConnectionStrings.cs b/Azaion.Common/Configs/ConnectionStrings.cs index 7db8c2f..62b8c62 100644 --- a/Azaion.Common/Configs/ConnectionStrings.cs +++ b/Azaion.Common/Configs/ConnectionStrings.cs @@ -3,4 +3,5 @@ public class ConnectionStrings { public string AzaionDb { get; set; } = null!; + public string AzaionDbAdmin { get; set; } = null!; } \ No newline at end of file diff --git a/Azaion.Common/Configs/JwtConfig.cs b/Azaion.Common/Configs/JwtConfig.cs index 635d70f..d7d04e7 100644 --- a/Azaion.Common/Configs/JwtConfig.cs +++ b/Azaion.Common/Configs/JwtConfig.cs @@ -4,5 +4,6 @@ public class JwtConfig { public string Issuer { get; set; } = null!; public string Audience { get; set; } = null!; - public string Secret { get; set; } + public string Secret { get; set; } = null!; + public double TokenLifetimeHours { get; set; } } \ No newline at end of file diff --git a/Azaion.Common/Database/AzaionDbShemaHolder.cs b/Azaion.Common/Database/AzaionDbShemaHolder.cs index 4b03c44..ee4b166 100644 --- a/Azaion.Common/Database/AzaionDbShemaHolder.cs +++ b/Azaion.Common/Database/AzaionDbShemaHolder.cs @@ -10,11 +10,19 @@ public static class AzaionDbSchemaHolder static AzaionDbSchemaHolder() { MappingSchema = new MappingSchema(); + + MappingSchema.EntityDescriptorCreatedCallback = (_, entityDescriptor) => + { + foreach (var entityDescriptorColumn in entityDescriptor.Columns) + entityDescriptorColumn.ColumnName = entityDescriptorColumn.ColumnName.ToSnakeCase(); + }; + var builder = new FluentMappingBuilder(MappingSchema); builder.Entity() .HasTableName("users") - .HasIdentity(x => x.Id); + .HasIdentity(x => x.Id) + .Property(x => x.Role).HasConversion(v => v.ToString(), v => (RoleEnum)Enum.Parse(typeof(RoleEnum), v)); builder.Build(); } diff --git a/Azaion.Common/Database/DbFactory.cs b/Azaion.Common/Database/DbFactory.cs index 600e298..20eccf7 100644 --- a/Azaion.Common/Database/DbFactory.cs +++ b/Azaion.Common/Database/DbFactory.cs @@ -1,5 +1,7 @@ using System.Diagnostics; +using Azaion.Common.Configs; using LinqToDB; +using Microsoft.Extensions.Options; namespace Azaion.Common.Database; @@ -7,42 +9,49 @@ public interface IDbFactory { Task Run(Func> func); Task Run(Func func); - - T Run(Func func); + Task RunAdmin(Func func); } public class DbFactory : IDbFactory { private readonly DataOptions _dataOptions; + private readonly DataOptions _dataOptionsAdmin; - public DbFactory(string connectionString, bool useTracing = true, bool msSql = false) + public DbFactory(IOptions connectionString) { - if (string.IsNullOrEmpty(connectionString)) - throw new ArgumentException("Empty connectionString", nameof(connectionString)); + _dataOptions = LoadOptions(connectionString.Value.AzaionDb); + _dataOptionsAdmin = LoadOptions(connectionString.Value.AzaionDbAdmin); + } - _dataOptions = new DataOptions() - .UsePostgreSQL(connectionString) + private DataOptions LoadOptions(string connStr) + { + if (string.IsNullOrEmpty(connStr)) + throw new ArgumentException($"Empty connection string in config!"); + + var dataOptions = new DataOptions() + .UsePostgreSQL(connStr) .UseMappingSchema(AzaionDbSchemaHolder.MappingSchema); - if (useTracing) - _ = _dataOptions.UseTracing(TraceLevel.Info, t => Console.WriteLine(t.SqlText)); + _ = dataOptions.UseTracing(TraceLevel.Info, t => Console.WriteLine(t.SqlText)); + return dataOptions; } + public async Task Run(Func> func) { await using var db = new AzaionDb(_dataOptions); return await func(db); } - + public async Task Run(Func func) { await using var db = new AzaionDb(_dataOptions); await func(db); } - - public T Run(Func func) + + public async Task RunAdmin(Func func) { - using var db = new AzaionDb(_dataOptions); - return func(db); + await using var db = new AzaionDb(_dataOptionsAdmin); + await func(db); } } diff --git a/Azaion.Common/Entities/User.cs b/Azaion.Common/Entities/User.cs index c8b7a96..69df6fd 100644 --- a/Azaion.Common/Entities/User.cs +++ b/Azaion.Common/Entities/User.cs @@ -2,7 +2,7 @@ public class User { - public string Id { get; set; } = null!; + public Guid Id { get; set; } public string Email { get; set; } = null!; public string PasswordHash { get; set; } = null!; public string HardwareId { get; set; } = null!; diff --git a/Azaion.Common/StringExtensions.cs b/Azaion.Common/StringExtensions.cs new file mode 100644 index 0000000..b2fba24 --- /dev/null +++ b/Azaion.Common/StringExtensions.cs @@ -0,0 +1,28 @@ +using System.Text; + +namespace Azaion.Common; + +public static class StringExtensions +{ + public static string ToSnakeCase(this string text) + { + if (string.IsNullOrEmpty(text)) + return text; + + if (text.Length < 2) + return text.ToLowerInvariant(); + + var sb = new StringBuilder(); + sb.Append(char.ToLowerInvariant(text[0])); + for (int i = 1; i < text.Length; ++i) { + var c = text[i]; + if(char.IsUpper(c)) { + sb.Append('_'); + sb.Append(char.ToLowerInvariant(c)); + } else { + sb.Append(c); + } + } + return sb.ToString(); + } +} \ No newline at end of file diff --git a/Azaion.Services/AuthService.cs b/Azaion.Services/AuthService.cs new file mode 100644 index 0000000..8e54a18 --- /dev/null +++ b/Azaion.Services/AuthService.cs @@ -0,0 +1,63 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Text; +using Azaion.Common.Configs; +using Azaion.Common.Entities; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Options; +using Microsoft.IdentityModel.Tokens; + +namespace Azaion.Services; + +public interface IAuthService +{ + User? CurrentUser { get; } + string CreateToken(User user); +} + +public class AuthService(IHttpContextAccessor httpContextAccessor, IOptions jwtConfig) : IAuthService +{ + public User? CurrentUser + { + get + { + var claims = httpContextAccessor.HttpContext?.User.Claims.ToDictionary(x => x.Type); + if (claims == null) + return null; + + if (!Enum.TryParse(claims[ClaimTypes.Role].Value, out RoleEnum role)) + throw new ApplicationException("Invalid role"); + + return new User + { + Id = Guid.Parse(claims[ClaimTypes.NameIdentifier].Value), + Email = claims[ClaimTypes.Name].Value, + Role = role, + HardwareId = claims[Constants.HARDWARE_ID].Value, + }; + } + } + + public string CreateToken(User user) + { + var signingKey = new SymmetricSecurityKey(Encoding.ASCII.GetBytes(jwtConfig.Value.Secret)); + + var tokenHandler = new JwtSecurityTokenHandler(); + var tokenDescriptor = new SecurityTokenDescriptor + { + Subject = new ClaimsIdentity([ + new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()), + new Claim(ClaimTypes.Name, user.Email), + new Claim(ClaimTypes.Role, user.Role.ToString()), + new Claim(Constants.HARDWARE_ID, user.HardwareId) + ]), + Expires = DateTime.UtcNow.AddHours(jwtConfig.Value.TokenLifetimeHours), + Issuer = jwtConfig.Value.Issuer, + Audience = jwtConfig.Value.Audience, + SigningCredentials = new SigningCredentials(signingKey, SecurityAlgorithms.HmacSha256Signature) + }; + + var token = tokenHandler.CreateToken(tokenDescriptor); + return tokenHandler.WriteToken(token); + } +} \ No newline at end of file diff --git a/Azaion.Services/Azaion.Services.csproj b/Azaion.Services/Azaion.Services.csproj index 6429d28..8d29b47 100644 --- a/Azaion.Services/Azaion.Services.csproj +++ b/Azaion.Services/Azaion.Services.csproj @@ -19,4 +19,8 @@ + + + + diff --git a/Azaion.Services/UserService.cs b/Azaion.Services/UserService.cs index 045e63c..fc19292 100644 --- a/Azaion.Services/UserService.cs +++ b/Azaion.Services/UserService.cs @@ -1,46 +1,23 @@ -using System.Security.Claims; -using Azaion.Common; -using Azaion.Common.Configs; +using Azaion.Common; 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(string username, string password, string? hardwareId = null, CancellationToken cancellationToken = default); Task UpdateHardwareId(string username, string hardwareId, CancellationToken cancellationToken = default); } -public class UserService(IDbFactory dbFactory, IHttpContextAccessor httpContextAccessor) : IUserService +public class UserService(IDbFactory dbFactory) : 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 => + await dbFactory.RunAdmin(async db => { var existingUser = await db.Users.FirstOrDefaultAsync(u => u.Email == request.Email, token: cancellationToken); if (existingUser != null) @@ -75,6 +52,6 @@ public class UserService(IDbFactory dbFactory, IHttpContextAccessor httpContextA }); public async Task UpdateHardwareId(string username, string hardwareId, CancellationToken cancellationToken = default) => - await dbFactory.Run(async db => + await dbFactory.RunAdmin(async db => await db.Users.UpdateAsync(x => x.Email == username, u => new User { HardwareId = hardwareId}, token: cancellationToken)); } diff --git a/env/azaion-db.sql b/env/azaion-db.sql new file mode 100644 index 0000000..3a54e78 --- /dev/null +++ b/env/azaion-db.sql @@ -0,0 +1,29 @@ +create database azaion; +-- make sure you connect to azaion db + +--superadmin user +create role azaion_superadmin with login password 'superadmin_pass'; +grant all privileges on all tables in schema public to azaion_superadmin; + +--writer user +create role azaion_admin with login password 'admin_pass'; +grant connect on database azaion to azaion_admin; +grant usage on schema public to azaion_admin; + +--readonly user +create role azaion_reader with login password 'reader_pass'; +grant connect on database azaion to azaion_reader; +grant usage on schema public to azaion_reader; + + +-- users table +create table users +( + id uuid primary key, + email varchar(160) not null, + password_hash varchar(255) not null, + hardware_id varchar(120) not null, + role varchar(20) not null +); +grant select, insert, update, delete on public.users to azaion_admin; +grant select on table public.users to azaion_reader;