namespace MyOffice.Web; using System.IO; using System.Linq; using Core.Extensions; using System.Collections.Concurrent; using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.FileProviders.Physical; using Microsoft.Extensions.Logging; using Microsoft.IdentityModel.Logging; using Microsoft.AspNetCore.Builder; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Options; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Configuration; using Identity; using Identity.Domain; using Identity.ExternalProviders; using Identity.Repositories; using IdentityServer4.AccessTokenValidation; using IdentityServer4.Configuration; using IdentityServer4.Extensions; using IdentityServer4.Models; using Microsoft.IdentityModel.Tokens; using System.Security.Cryptography; using System.Text.Json; using System.Text.Json.Serialization; using Core.Identity; using Data.Repositories.Account; using Data.Repositories.Currency; using Data.Repositories.Item; using Data.Repositories.Users; using DbContext; using Services.Users; using Shared; using Identity.Configure; using Infrastructure; using Microsoft.Extensions.Logging.Console; using IdentityServer4.Services; using Models.Account; using MyOffice.Services.Currency; using Services.Account; using Services.Item; using MyOffice.Services.Dashboard; using MyOffice.Services.Identity; using AutoMapper; using Models.Motion; using MyOffice.Services.Account.Domain; using MyOffice.Services.Mapper; //TODO: Data.Model only Repository and Service, response <-> mapper <-> web <-> mapper <-> service <-> repository //TODO: Project management //TODO: Model names. Controller = xxxRequest/xxxResponse. Service xxxInput/xxxOutput. //TODO: Validate string as Guid when id //TODO: Test back //TODO: Test front //TODO: Test DB ??? //TODO: Email confirmation //TODO: Password recovery //TODO: Account sharing //TODO: /dashboard/dashboard -> /dashboard/main or /dashboard //TODO: SPA load categories twice menu + setting (cache) //TODO: SPA update account category -> update menu //TODO: Response model from base type //TODO: XXXResult -> XXXStatus //TODO: SPA isAllowDelete -> allowDelete (remove is) public class Program { public static void Main(string[] args) { var builder = WebApplication.CreateBuilder(args); AddServices(builder); var app = builder.Build(); Configure(app); app.Run(); } private static void AddServices(WebApplicationBuilder builder) { builder.Services.AddLogging(logging => logging.AddSimpleConsole(options => { //options.SingleLine = true; options.TimestampFormat = "[HH:mm:ss] "; options.ColorBehavior = LoggerColorBehavior.Enabled; }) ); //File Logger builder.Logging.AddFile(builder.Configuration.GetSection("Logging")); builder.Services.Configure(options => { //options.AddFilter("IdentityServer4", LogLevel.Warning); // or LogLevel.None to completely disable options.AddFilter("IdentityServer4", LogLevel.None); // or LogLevel.None to completely disable }); // shared configuration builder.Configuration.AddConfiguration(SharedConfiguration.CreateConfigurationContainer()); // Database var databaseProvider = builder.Configuration["DatabaseProvider"]; if (databaseProvider == null) throw new NullReferenceException($"Configuration DatabaseProvider {databaseProvider}"); var connectionString = builder.Configuration.GetConnectionString(databaseProvider); if (connectionString == null) throw new NullReferenceException($"Configuration ConnectionString {databaseProvider}"); var connectionConfiguration = new ConnectionConfiguration(databaseProvider, connectionString); RepositoryInitializer.Initialize(builder.Services, connectionConfiguration); // Configurations builder.Services.Configure( builder.Configuration.GetSection("ExternalProviders") ); InitializeGlobalSettings(builder); AddIdentityServices(builder); builder.Services.AddScoped(); AddMapping(builder); AddRepositories(builder); AddBusinessServices(builder); builder.Services .AddControllers() .AddJsonOptions(options => { options.AllowInputFormatterExceptionMessages = builder.Environment.IsDevelopment(); options.JsonSerializerOptions.MaxDepth = 0; options.JsonSerializerOptions.ReferenceHandler = ReferenceHandler.IgnoreCycles; options.JsonSerializerOptions.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull; }); // Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle //builder.Services.AddEndpointsApiExplorer(); //builder.Services.AddSwaggerGen(); builder.Services.AddCors(); } private static void InitializeGlobalSettings(WebApplicationBuilder builder) { builder.Services.Configure(_ => { }); builder.Services.AddSingleton(resolver => resolver.GetRequiredService>().Value); } private static void AddIdentityServices(WebApplicationBuilder builder) { // add identity builder.Services .AddIdentity, ApplicationRole>() .AddUserStore() .AddRoleStore() .AddUserManager() .AddDefaultTokenProviders(); builder.Services.AddScoped(); builder.Services.AddScoped>, PasswordHasher>(); // Identity Services builder.Services.AddScoped>, UserStore>(); builder.Services.AddScoped, RoleStore>(); // External providers builder.Services.AddScoped(); builder.Services.AddScoped(); // Configure Identity options and password complexity here builder.Services.Configure(options => { // User settings options.User.RequireUniqueEmail = true; // Password settings options.Password.RequireDigit = true; options.Password.RequiredLength = 8; options.Password.RequireNonAlphanumeric = true; options.Password.RequireUppercase = true; options.Password.RequireLowercase = true; // Lockout settings options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(30); options.Lockout.MaxFailedAccessAttempts = 10; }); // Adds IdentityServer. builder.Services .AddIdentityServer(o => { o.UserInteraction = new UserInteractionOptions() { LoginUrl = "/authentication/signin", LoginReturnUrlParameter = "returnUrl", }; }) .AddSigningCredential(CreateSigningCredential(builder)) .AddPersistedGrantStore() .AddInMemoryIdentityResources(IdentityServerConfig.GetIdentityResources()) .AddInMemoryApiScopes(IdentityServerConfig.GetApiScopes()) .AddInMemoryApiResources(IdentityServerConfig.GetApiResources()) .AddInMemoryClients(IdentityServerConfig.GetClients()) .AddAspNetIdentity>() .AddProfileService() .AddExtensionGrantValidator(); var authentication = builder.Services .AddAuthentication(options => { options.DefaultScheme = IdentityServerAuthenticationDefaults.AuthenticationScheme; options.DefaultAuthenticateScheme = IdentityServerAuthenticationDefaults.AuthenticationScheme; options.DefaultChallengeScheme = IdentityServerAuthenticationDefaults.AuthenticationScheme; }); var frontEndHost = builder.Configuration.GetValue("FrontEnd:Host"); var isDynamicFrontEndHost = frontEndHost.IsMissing(); if (isDynamicFrontEndHost) { builder.Services.ConfigureOptions(); authentication.AddIdentityServerAuthentication(); } else { authentication.AddIdentityServerAuthentication(options => { options.RequireHttpsMetadata = false; options.ApiName = IdentityServerConfig.ApiName; options.Authority = frontEndHost; }); } } private static void AddMapping(WebApplicationBuilder builder) { builder.Services.AddAutoMapper( c => { c.AllowNullCollections = true; c.AllowNullDestinationValues = true; }, typeof(AccountViewModelProfile), typeof(AccountServiceProfile) ); } private static void AddRepositories(WebApplicationBuilder builder) { builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); } private static void AddBusinessServices(WebApplicationBuilder builder) { builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); } private static readonly ConcurrentDictionary _staticFilesCache = new(); private static bool _firstRequest = true; private static readonly object LockFirstRequest = new(); private static void Configure(WebApplication app) { // Configure the HTTP request pipeline. if (app.Environment.IsDevelopment()) { app.UseDeveloperExceptionPage(); IdentityModelEventSource.ShowPII = true; } else { IdentityModelEventSource.ShowPII = true; app.UseExceptionHandler("/Error"); } app.UseDefaultFiles(); app.UseStaticFiles(); var logger = app.Logger; // configuration on first request app.Use(async (ctx, next) => { var path = ctx.Request.Path; GlobalSettings? globalSettings = null; if (_firstRequest) { // lock lock (LockFirstRequest) { // recheck if (_firstRequest) { _firstRequest = false; globalSettings = ctx.RequestServices.GetRequiredService(); var configuration = ctx.RequestServices.GetRequiredService(); var frontEndHost = configuration.GetValue("FrontEnd:Host"); var isDynamicFrontEndHost = frontEndHost.IsMissing(); if (!isDynamicFrontEndHost) { globalSettings.Host = frontEndHost; } else { // set real frontend host globalSettings.Host = GetFrontendHost(ctx, logger); } // update identity server var options = ctx.RequestServices.GetRequiredService(); options.IssuerUri = globalSettings.Host; // update identity server client redirect uri var clients = ctx.RequestServices.GetRequiredService>(); var client = clients.FirstOrDefault()!; client.RedirectUris = new List { $"{globalSettings.Host}/silent-refresh.html" }; } } } // if (path.Value.EqualsIgnoreCase("/.well-known/openid-configuration")) { globalSettings ??= ctx.RequestServices.GetRequiredService(); logger.LogInformation($"OC Host: {globalSettings.Host}"); ctx.SetIdentityServerOrigin(globalSettings.Host); var options = ctx.RequestServices.GetRequiredService(); options.IssuerUri = globalSettings.Host; } await next(); }); // send index.html to any routes except coreRoutes var coreRoutes = new[] { // API routes new PathString("/api"), // Identity server routes new PathString("/.well-known"), new PathString("/connect"), new PathString("/silent-refresh.html") }; var webRootPath = app.Configuration.GetValue(WebHostDefaults.ContentRootKey); var wwwrootPath = Path.Combine(webRootPath, "wwwroot"); app.Use(async (context, next) => { var path = context.Request.Path; if (path.Value == null) return; if (coreRoutes.Any(x => path.StartsWithSegments(x, StringComparison.OrdinalIgnoreCase))) { await next(); } else { if (!_staticFilesCache.TryGetValue(path.Value, out var physicalFileInfo)) { var segments = path.Value.Split('/', '\\'); var fileInfo = new FileInfo(Path.Combine(wwwrootPath, segments.LastOrDefault()!)); if (!fileInfo.Exists) { return; } physicalFileInfo = new PhysicalFileInfo(fileInfo); _staticFilesCache.TryAdd(path.Value, physicalFileInfo); } await context.Response.SendFileAsync(physicalFileInfo); await context.Response.CompleteAsync(); } }); app.UseCors(builder => builder .AllowAnyOrigin() .AllowAnyHeader() .AllowAnyMethod()); app.UseIdentityServer(); app.UseAuthorization(); app.MapControllers(); } private static string GetFrontendHost(HttpContext ctx, ILogger logger) { // internet <-> nginx (https) <-> site(http) var host = $"{ctx.Request.Headers["Referer"]}"; host = host.EndsWith("/") ? host.Substring(0, host.Length - 1) : host; return host; } private static SigningCredentials CreateSigningCredential(WebApplicationBuilder builder) { var rsa = RSA.Create(); var webRootPath = builder.Configuration.GetValue(WebHostDefaults.ContentRootKey); var privateKey = File.ReadAllText(Path.Combine(webRootPath, "rsa_key.pem")); privateKey = privateKey .Replace("-----BEGIN RSA PRIVATE KEY-----", string.Empty) .Replace("-----END RSA PRIVATE KEY-----", string.Empty) .Replace(Environment.NewLine, string.Empty); var privateKeyBytes = Convert.FromBase64String(privateKey); rsa.ImportRSAPrivateKey(privateKeyBytes, out int _); var rsaSecurityKey = new RsaSecurityKey(rsa); var credentials = new SigningCredentials(rsaSecurityKey, SecurityAlgorithms.RsaSha256); return credentials; } }