diff --git a/.gitignore b/.gitignore index 986ec50..c50af21 100644 --- a/.gitignore +++ b/.gitignore @@ -482,3 +482,7 @@ $RECYCLE.BIN/ # Vim temporary swap files *.swp + +# App data files and data for dev +/data +/data_dev diff --git a/OptixServe.Api/Configuration/AppSettings.cs b/OptixServe.Api/Configuration/AppSettings.cs new file mode 100644 index 0000000..7a53c2f --- /dev/null +++ b/OptixServe.Api/Configuration/AppSettings.cs @@ -0,0 +1,34 @@ +namespace OptixServe.Api.Configuration; + +public record OptixServeSettings +{ + public ApiSettings? Api { get; set; } = new(); + public DatabaseSettings? Database { get; set; } = new(); + public JwtSettings? Jwt { get; set; } = new(); +} + +public record ApiSettings +{ + public string? Listen { get; set; } = "127.0.0.1"; + public int? Port { get; set; } = 10086; +} + +public record JwtSettings +{ + public string Secret { get; set; } = string.Empty; + public string Issuer { get; set; } = "OptixServe"; + public string Audience { get; set; } = "OptixServeUsers"; + public int TokenExpirationMinutes { get; set; } = 60; +} + +public enum DatabaseType +{ + Sqlite, + MySQL +} + +public record DatabaseSettings +{ + public DatabaseType Type { get; set; } = DatabaseType.Sqlite; + public string? Host { get; set; } +} \ No newline at end of file diff --git a/OptixServe.Api/Configuration/ConfigurationHelper.cs b/OptixServe.Api/Configuration/ConfigurationHelper.cs new file mode 100644 index 0000000..4c222e3 --- /dev/null +++ b/OptixServe.Api/Configuration/ConfigurationHelper.cs @@ -0,0 +1,27 @@ +using System; + +namespace OptixServe.Api.Configuration; + +public static class ConfigurationHelper +{ + public static IConfigurationBuilder CreateDefaultBuilder() + { + var aspEnv = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT"); + var netEnv = Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT"); + // Console.WriteLine($"ASPNETCORE_ENVIRONMENT: {aspEnv}, DOTNET_ENVIRONMENT: {netEnv}"); + var env = aspEnv ?? netEnv ?? null; + + var builder = new ConfigurationBuilder() + .SetBasePath(Directory.GetCurrentDirectory()) + .AddJsonFile("appsettings.json", optional: true) + .AddJsonFile("config.json", optional: true); + + if (env != null) + { + builder.AddJsonFile($"appsettings.{env}.json", optional: true) + .AddJsonFile($"config.{env}.json", optional: true); + } + + return builder; + } +} \ No newline at end of file diff --git a/OptixServe.Api/Dtos/Auth.cs b/OptixServe.Api/Dtos/Auth.cs new file mode 100644 index 0000000..c867991 --- /dev/null +++ b/OptixServe.Api/Dtos/Auth.cs @@ -0,0 +1,12 @@ +namespace OptixServe.Api.Dtos; + +public record LoginRequestDto +{ + public string? UserName { get; set; } + public string? Password { get; set; } +} + +public record LoginResponseDto +{ + public string? Token { get; set; } +} \ No newline at end of file diff --git a/OptixServe.Api/Dtos/Error.cs b/OptixServe.Api/Dtos/Error.cs new file mode 100644 index 0000000..eb1d505 --- /dev/null +++ b/OptixServe.Api/Dtos/Error.cs @@ -0,0 +1,6 @@ +namespace OptixServe.Api.Dtos; + +public record CommonErrorDto +{ + public string? Message { get; set; } +} \ No newline at end of file diff --git a/OptixServe.Api/Endpoints/UserEndpoint.cs b/OptixServe.Api/Endpoints/UserEndpoint.cs index add9286..24abe86 100644 --- a/OptixServe.Api/Endpoints/UserEndpoint.cs +++ b/OptixServe.Api/Endpoints/UserEndpoint.cs @@ -1,32 +1,61 @@ using System.Text.Json.Serialization; +using OptixServe.Core.Services; using OptixServe.Api.Dtos; +using OptixServe.Api.Services; +using Microsoft.AspNetCore.Authorization; namespace OptixServe.Api.Endpoints; [JsonSerializable(typeof(UserDto))] [JsonSerializable(typeof(IEnumerable))] +[JsonSerializable(typeof(LoginRequestDto))] +[JsonSerializable(typeof(LoginResponseDto))] // For returning the token string public partial class UserJsonContext : JsonSerializerContext { } public static class UserEndpoint { - public static IEnumerable GetUsers() + public static void Register(RouteGroupBuilder parentGroup) { - return [ - new() {Id="1234", UserName = "xxx"}, - new() {Id="5678", UserName = "yyy"}, - ]; + var group = parentGroup.MapGroup("/users"); + + group.MapPost("/login", LoginUser); + group.MapGet("/", GetAllUsers).RequireAuthorization(); + group.MapGet("/{id}", GetUserById).RequireAuthorization(); } - public static void Register(WebApplication app) + public static IResult LoginUser(LoginRequestDto loginRequest, IUserService userService, ITokenService tokenService) { - var group = app.MapGroup("/users"); + if (string.IsNullOrEmpty(loginRequest.UserName) || string.IsNullOrEmpty(loginRequest.Password)) + { + return Results.BadRequest("Username and password are required."); + } - group.MapGet("/", GetAllUsers); + // Password hashing and salting will be implemented later. + var user = userService.GetUserByUsername(loginRequest.UserName); + + if (user == null || user.Password != loginRequest.Password) + { + return Results.Unauthorized(); + } + + var token = tokenService.GenerateToken(user); + return Results.Ok(new LoginResponseDto { Token = token }); } - public static IResult GetAllUsers() + public static IResult GetAllUsers(IUserService userService) { - return Results.Ok(GetUsers()); + var users = userService.GetUsers() + .Select(u => new UserDto { Id = u.Id, UserName = u.UserName }); + return Results.Ok(users); } -} \ No newline at end of file + + public static IResult GetUserById(string id, IUserService userService) + { + var user = userService.GetUserById(id); + if (user == null) + return Results.NotFound(); + + return Results.Ok(new UserDto { Id = user.Id, UserName = user.UserName }); + } +} diff --git a/OptixServe.Api/Endpoints/VersionEndpoint.cs b/OptixServe.Api/Endpoints/VersionEndpoint.cs new file mode 100644 index 0000000..6eb01ec --- /dev/null +++ b/OptixServe.Api/Endpoints/VersionEndpoint.cs @@ -0,0 +1,35 @@ +using System.Text.Json.Serialization; +using Microsoft.Extensions.Options; +using OptixServe.Api.Configuration; +using OptixServe.Api.Dtos; + +namespace OptixServe.Api.Endpoints; + + +[JsonSerializable(typeof(string))] +[JsonSerializable(typeof(CommonErrorDto))] +public partial class VersionJsonContext : JsonSerializerContext { } + + +/// +/// This is a endpoint ONLY FOR TEST! +/// Should not expect ANY stable behavior on it! +/// +public static class VersionEndpoint +{ + public static void Register(RouteGroupBuilder parentGroup) + { + var group = parentGroup.MapGroup("/version"); + + group.MapGet("/", () => "v1"); + group.MapGet("/test/dbconfig", (IOptions appSettings) => + { + var dbType = appSettings.Value.Database?.Type; + var dbHost = appSettings.Value.Database?.Host; + return Results.Ok(new CommonErrorDto + { + Message = $"Set up {dbType} database on {dbHost}" + }); + }); + } +} \ No newline at end of file diff --git a/OptixServe.Api/OptixServe.Api.csproj b/OptixServe.Api/OptixServe.Api.csproj index 3815467..c3e18e4 100644 --- a/OptixServe.Api/OptixServe.Api.csproj +++ b/OptixServe.Api/OptixServe.Api.csproj @@ -1,11 +1,17 @@ - - + + - - + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + @@ -13,7 +19,7 @@ enable enable true - true + false diff --git a/OptixServe.Api/Program.cs b/OptixServe.Api/Program.cs index 5f29100..6d25783 100644 --- a/OptixServe.Api/Program.cs +++ b/OptixServe.Api/Program.cs @@ -1,5 +1,13 @@ using System.CommandLine; +using System.Text; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.IdentityModel.Tokens; +using OptixServe.Api.Configuration; using OptixServe.Api.Endpoints; +using OptixServe.Api.Services; +using OptixServe.Core.Data; +using OptixServe.Core.Services; +using OptixServe.Api.Utilites; class Program { @@ -41,10 +49,22 @@ class Program var configFile = parseResult.GetValue(configOption); builder.AddConfigurationWithCommand(configFile); + builder.RegisterServices(); builder.RegiserJsonContext(); var app = builder.Build(); - app.RegisterEndpoints(); + + app.UseAuthentication(); + app.UseAuthorization(); + + using (var scope = app.Services.CreateScope()) + { + var initializer = scope.ServiceProvider.GetRequiredService(); + initializer.Initialize(); + } + + var apiGroup = app.MapGroup("api/v1"); + StartupHelper.RegisterEndpoints(apiGroup); app.Run(); }); @@ -59,29 +79,8 @@ class Program /// /// Contains extension methods for WebApplicationBuilder and WebApplication /// -static class ExtensionMethods +static class StartupHelper { - /// - /// Registers all API endpoints - /// - /// WebApplication instance - public static void RegisterEndpoints(this WebApplication app) - { - UserEndpoint.Register(app); - } - - /// - /// Configures JSON serialization options with custom context - /// - /// WebApplicationBuilder instance - public static void RegiserJsonContext(this WebApplicationBuilder builder) - { - builder.Services.ConfigureHttpJsonOptions(options => - { - options.SerializerOptions.TypeInfoResolverChain.Add(UserJsonContext.Default); - }); - } - /// /// Adds configuration sources to the application builder /// @@ -105,4 +104,78 @@ static class ExtensionMethods builder.Configuration.AddConfiguration(configurationBuilder.Build()); } + + /// + /// Configures DbContext services + /// + /// + /// + /// + public static IServiceCollection AddAppDatabase(this IServiceCollection services, DatabaseSettings dbSettings) + { + services.AddDbContext(options => DatabaseHelper.ConfigureDbContext(options, dbSettings)); + return services; + } + + /// + /// Configures services for DI + /// + /// WebApplicationBuilder instance + public static void RegisterServices(this WebApplicationBuilder builder) + { + // Add configuration class + var optixSettigns = builder.Configuration.GetSection("OptixServe"); + var onConfigSettings = optixSettigns.Get(); + builder.Services.Configure(optixSettigns); + + // Add DBContext class + builder.Services.AddAppDatabase(onConfigSettings?.Database!); + builder.Services.AddScoped(); + + // Application services + builder.Services.AddScoped(); + builder.Services.AddScoped(); + + // Add Authentication and Authorization + builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddJwtBearer(options => + { + var jwtSettings = onConfigSettings?.Jwt ?? throw new ArgumentNullException(nameof(builder), "JWT settings are not configured."); + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidateAudience = true, + ValidateLifetime = true, + ValidateIssuerSigningKey = true, + ValidIssuer = jwtSettings.Issuer, + ValidAudience = jwtSettings.Audience, + IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtSettings.Secret)) + }; + }); + builder.Services.AddAuthorization(); + } + + /// + /// Configures JSON serialization options with custom context + /// + /// WebApplicationBuilder instance + public static void RegiserJsonContext(this WebApplicationBuilder builder) + { + builder.Services.ConfigureHttpJsonOptions(options => + { + options.SerializerOptions.TypeInfoResolverChain.Add(UserJsonContext.Default); + options.SerializerOptions.TypeInfoResolverChain.Add(VersionJsonContext.Default); + }); + } + + /// + /// Registers all API endpoints + /// + /// Root RouteGroupBuilder instance + public static void RegisterEndpoints(RouteGroupBuilder rootGroup) + { + UserEndpoint.Register(rootGroup); + VersionEndpoint.Register(rootGroup); + } + } \ No newline at end of file diff --git a/OptixServe.Api/Services/TokenService.cs b/OptixServe.Api/Services/TokenService.cs new file mode 100644 index 0000000..f124f92 --- /dev/null +++ b/OptixServe.Api/Services/TokenService.cs @@ -0,0 +1,44 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Text; +using Microsoft.Extensions.Options; +using Microsoft.IdentityModel.Tokens; +using OptixServe.Api.Configuration; +using OptixServe.Core.Models; + +namespace OptixServe.Api.Services; + +public interface ITokenService +{ + public string GenerateToken(User user); +} + +public class TokenService(IOptions optixServeSettings) : ITokenService +{ + private readonly JwtSettings _jwtSettings = optixServeSettings.Value.Jwt ?? throw new ArgumentNullException(nameof(optixServeSettings), "JWT settings are not configured."); + + public string GenerateToken(User user) + { + var tokenHandler = new JwtSecurityTokenHandler(); + var key = Encoding.ASCII.GetBytes(_jwtSettings.Secret); + + var claims = new List + { + new (ClaimTypes.NameIdentifier, user.Id.ToString()), + new (ClaimTypes.Name, user.UserName) + // Add roles if applicable: new Claim(ClaimTypes.Role, user.Role) + }; + + var tokenDescriptor = new SecurityTokenDescriptor + { + Subject = new ClaimsIdentity(claims), + Expires = DateTime.UtcNow.AddMinutes(_jwtSettings.TokenExpirationMinutes), + Issuer = _jwtSettings.Issuer, + Audience = _jwtSettings.Audience, + SigningCredentials = new SigningCredentials(new SymmetricSecurityKey(key), SecurityAlgorithms.HmacSha256Signature) + }; + + var token = tokenHandler.CreateToken(tokenDescriptor); + return tokenHandler.WriteToken(token); + } +} \ No newline at end of file diff --git a/OptixServe.Api/Utilites/DatabaseHelper.cs b/OptixServe.Api/Utilites/DatabaseHelper.cs new file mode 100644 index 0000000..3107d66 --- /dev/null +++ b/OptixServe.Api/Utilites/DatabaseHelper.cs @@ -0,0 +1,34 @@ +using Microsoft.EntityFrameworkCore; +using OptixServe.Api.Configuration; +using OptixServe.Core.Data; + +namespace OptixServe.Api.Utilites; + +public static class DatabaseHelper +{ + public static string BuildConnectionString(DatabaseSettings dbSettings) + { + return dbSettings.Type switch + { + DatabaseType.Sqlite => $"Data Source={dbSettings.Host ?? "optixserve.db"}", + DatabaseType.MySQL => throw new NotSupportedException("MySQL connection is not yet implemented"), + _ => throw new NotSupportedException($"Database type {dbSettings.Type} is not supported") + }; + } + + public static void ConfigureDbContext(DbContextOptionsBuilder options, DatabaseSettings dbSettings) + { + if (dbSettings?.Type == DatabaseType.Sqlite) + { + var dbPath = dbSettings.Host ?? "optixserve.db"; + var connectionString = $"Data Source={dbPath}"; + + options.UseSqlite(connectionString, b => b.MigrationsAssembly("OptixServe.Api")); + + } + else + { + throw new NotImplementedException("Only SQLite database is currently supported"); + } + } +} \ No newline at end of file diff --git a/OptixServe.Api/Utilites/DesignTimeDbContextFactory.cs b/OptixServe.Api/Utilites/DesignTimeDbContextFactory.cs new file mode 100644 index 0000000..f9d01d8 --- /dev/null +++ b/OptixServe.Api/Utilites/DesignTimeDbContextFactory.cs @@ -0,0 +1,20 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; +using OptixServe.Api.Configuration; +using OptixServe.Core.Data; + +namespace OptixServe.Api.Utilites; + +public class DesignTimeDbContextFactory : IDesignTimeDbContextFactory +{ + public AppDbContext CreateDbContext(string[] args) + { + var configuration = ConfigurationHelper.CreateDefaultBuilder().Build(); + + var dbSettings = configuration.GetSection("OptixServe:Database").Get()!; + var optionsBuilder = new DbContextOptionsBuilder(); + DatabaseHelper.ConfigureDbContext(optionsBuilder, dbSettings); + + return new AppDbContext(optionsBuilder.Options); + } +} \ No newline at end of file diff --git a/OptixServe.Api/appsettings.json b/OptixServe.Api/appsettings.json index 4d56694..e4a86f3 100644 --- a/OptixServe.Api/appsettings.json +++ b/OptixServe.Api/appsettings.json @@ -5,5 +5,21 @@ "Microsoft.AspNetCore": "Warning" } }, - "AllowedHosts": "*" -} + "AllowedHosts": "*", + "OptixServe": { + "Api": { + "Listen": "0.0.0.0", + "Port": "54321" + }, + "Database": { + "Type": "Sqlite", + "Host": "optixserve.db" + }, + "Jwt": { + "Secret": "YOUR_SECRET_KEY_HERE_DO_NOT_SHARE_THIS_AND_MAKE_IT_LONG_ENOUGH", + "Issuer": "OptixServe", + "Audience": "OptixServeUsers", + "TokenExpirationMinutes": 60 + } + } +} \ No newline at end of file diff --git a/OptixServe.Core/Data/AppDbContext.cs b/OptixServe.Core/Data/AppDbContext.cs new file mode 100644 index 0000000..2116fc3 --- /dev/null +++ b/OptixServe.Core/Data/AppDbContext.cs @@ -0,0 +1,21 @@ +using Microsoft.EntityFrameworkCore; +using OptixServe.Core.Models; + +namespace OptixServe.Core.Data; + +public class AppDbContext(DbContextOptions options) : DbContext(options) +{ + public DbSet Users { get; set; } = null!; + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(user => + { + user.HasKey(u => u.Id); + }); + + modelBuilder.Entity().HasData([ + new() {Id = "1", UserName = "admin", Password = "admin12345"} + ]); + } +} \ No newline at end of file diff --git a/OptixServe.Core/Data/DbInitializer.cs b/OptixServe.Core/Data/DbInitializer.cs new file mode 100644 index 0000000..bec11cc --- /dev/null +++ b/OptixServe.Core/Data/DbInitializer.cs @@ -0,0 +1,11 @@ +namespace OptixServe.Core.Data; + +public class DbInitializer(AppDbContext dbContext) +{ + private readonly AppDbContext _context = dbContext; + + public void Initialize() + { + _context.Database.EnsureCreated(); + } +} \ No newline at end of file diff --git a/OptixServe.Core/Models/User.cs b/OptixServe.Core/Models/User.cs index 671e88e..3dbd0b4 100644 --- a/OptixServe.Core/Models/User.cs +++ b/OptixServe.Core/Models/User.cs @@ -1,8 +1,15 @@ namespace OptixServe.Core.Models; +public enum PrivilegeGroup +{ + Admin, + User, +} + public record User { public required string Id { get; set; } public required string UserName { get; set; } - public required string Password { get; set; } + public string? Password { get; set; } + public PrivilegeGroup PrivilegeGroup { get; set; } = PrivilegeGroup.User; } \ No newline at end of file diff --git a/OptixServe.Core/OptixServe.Core.csproj b/OptixServe.Core/OptixServe.Core.csproj index 125f4c9..354caa7 100644 --- a/OptixServe.Core/OptixServe.Core.csproj +++ b/OptixServe.Core/OptixServe.Core.csproj @@ -6,4 +6,8 @@ enable + + + + diff --git a/OptixServe.Core/Services/UserService.cs b/OptixServe.Core/Services/UserService.cs new file mode 100644 index 0000000..394f05c --- /dev/null +++ b/OptixServe.Core/Services/UserService.cs @@ -0,0 +1,31 @@ +using OptixServe.Core.Data; +using OptixServe.Core.Models; + +namespace OptixServe.Core.Services; + +public interface IUserService +{ + IEnumerable GetUsers(); + User? GetUserById(string id); + User? GetUserByUsername(string username); +} + +public class UserService(AppDbContext dbContext) : IUserService +{ + private readonly AppDbContext _dbContext = dbContext; + + public User? GetUserById(string id) + { + return _dbContext.Users.FirstOrDefault(u => u.Id == id); + } + + public User? GetUserByUsername(string username) + { + return _dbContext.Users.FirstOrDefault(u => u.UserName == username); + } + + public IEnumerable GetUsers() + { + return _dbContext.Users.AsEnumerable(); + } +} \ No newline at end of file