Files
myoffice/MyOffice.Web/Program.cs
T
2023-12-05 20:21:21 +02:00

489 lines
16 KiB
C#

namespace MyOffice.Web;
using System.IO;
using System.Linq;
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 IdentityServer4.AccessTokenValidation;
using IdentityServer4.Configuration;
using IdentityServer4.Extensions;
using IdentityServer4.Models;
using IdentityServer4.Services;
using Microsoft.IdentityModel.Tokens;
using Microsoft.Extensions.Logging.Console;
using System.Security.Cryptography;
using System.Text.Json.Serialization;
using Identity;
using Identity.Domain;
using Identity.ExternalProviders;
using Identity.Repositories;
using Core.Identity;
using Core.Extensions;
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 Models.Account;
using MyOffice.Services.Currency;
using Services.Account;
using Services.Item;
using MyOffice.Services.Dashboard;
using MyOffice.Services.Identity;
using MyOffice.Services.Account.Domain;
using MyOffice.Web.Infrastructure.Attributes;
using System.Text.Json;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Infrastructure;
//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)
//TODO: Repository auto registration
//TODO: Services auto registration
//TODO: openid-configuration failed - lock login
//TODO: BUG some times after login redirect to dashboard but exists return url
//TODO: automapper -> Extension ToModel() ToDbo() FromModel() FromDbo()
//TODO: SPA all http requests -> services
//TODO: Items, select category -> save url to allow refresh
//TODO: Accounts, select category -> save url to allow refresh
//TODO: report income
//TODO: report outcome
public partial 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)
{
#region Loging
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"));
#endregion Loging
builder.Services.Configure<LoggerFilterOptions>(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());
#region 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);
#endregion Database
// Configurations
builder.Services.Configure<ExternalProvidersConfig>(
builder.Configuration.GetSection("ExternalProviders")
);
InitializeGlobalSettings(builder);
AddIdentityServices(builder);
builder.Services.AddScoped<IContextProvider, ContextProvider>();
AddAutoMapper(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;
options.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;
options.JsonSerializerOptions.DictionaryKeyPolicy = JsonNamingPolicy.CamelCase;
});
// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
//builder.Services.AddEndpointsApiExplorer();
//builder.Services.AddSwaggerGen();
builder.Services.AddCors();
builder.Services.AddControllersWithViews(options =>
{
options.Conventions.Add(new DefaultFromBodyBindingConvention());
options.Filters.Add(typeof(GlobalModelStateValidatorAttribute));
});
builder.Services.Configure<MvcOptions>(options =>
{
});
builder.Services.Configure<ApiBehaviorOptions>(x => {
});
builder.Services.AddSingleton<ProblemDetailsFactory, CustomProblemDetailsFactory>();
}
private static void InitializeGlobalSettings(WebApplicationBuilder builder)
{
builder.Services.Configure<GlobalSettings>(_ => { });
builder.Services.AddSingleton(resolver => resolver.GetRequiredService<IOptions<GlobalSettings>>().Value);
}
private static void AddIdentityServices(WebApplicationBuilder builder)
{
// add identity
builder.Services
.AddIdentity<ApplicationUser<Guid>, ApplicationRole>()
.AddUserStore<UserStore>()
.AddRoleStore<RoleStore>()
.AddUserManager<AppUserManager>()
.AddDefaultTokenProviders();
builder.Services.AddScoped<ICorsPolicyService, CorsPolicyService>();
builder.Services.AddScoped<IPasswordHasher<ApplicationUser<Guid>>, PasswordHasher>();
// Identity Services
builder.Services.AddScoped<IUserStore<ApplicationUser<Guid>>, UserStore>();
builder.Services.AddScoped<IRoleStore<ApplicationRole>, RoleStore>();
// External providers
builder.Services.AddScoped<IExternalProviderValidator, ExternalProviderValidatorAuth0>();
builder.Services.AddScoped<IExternalProviderValidator, ExternalProviderValidatorGoogle>();
// Configure Identity options and password complexity here
builder.Services.Configure<IdentityOptions>(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<PersistedGrantStore>()
.AddInMemoryIdentityResources(IdentityServerConfig.GetIdentityResources())
.AddInMemoryApiScopes(IdentityServerConfig.GetApiScopes())
.AddInMemoryApiResources(IdentityServerConfig.GetApiResources())
.AddInMemoryClients(IdentityServerConfig.GetClients())
.AddAspNetIdentity<ApplicationUser<Guid>>()
.AddProfileService<ProfileService>()
.AddExtensionGrantValidator<ExtensionGrantValidator>();
var authentication = builder.Services
.AddAuthentication(options =>
{
options.DefaultScheme = IdentityServerAuthenticationDefaults.AuthenticationScheme;
options.DefaultAuthenticateScheme = IdentityServerAuthenticationDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = IdentityServerAuthenticationDefaults.AuthenticationScheme;
});
var frontEndHost = builder.Configuration.GetValue<string>("FrontEnd:Host");
var isDynamicFrontEndHost = frontEndHost.IsMissing();
if (isDynamicFrontEndHost)
{
builder.Services.ConfigureOptions<ConfigureIdentityServerOptions>();
authentication.AddIdentityServerAuthentication();
}
else
{
authentication.AddIdentityServerAuthentication(options =>
{
options.RequireHttpsMetadata = false;
options.ApiName = IdentityServerConfig.ApiName;
options.Authority = frontEndHost;
});
}
}
private static void AddAutoMapper(WebApplicationBuilder builder)
{
builder.Services.AddAutoMapper(
c =>
{
c.AllowNullCollections = true;
c.AllowNullDestinationValues = true;
},
typeof(AccountDto).Assembly,
typeof(AccountViewModel).Assembly
);
}
private static void AddRepositories(WebApplicationBuilder builder)
{
builder.Services.AddScoped<IUserRepository, UserRepository>();
builder.Services.AddScoped<IUserExternalRepository, UserExternalRepository>();
builder.Services.AddScoped<ICurrencyGlobalRepository, CurrencyGlobalRepository>();
builder.Services.AddScoped<ICurrencyRepository, CurrencyRepository>();
builder.Services.AddScoped<ICurrencyRateRepository, CurrencyRateRepository>();
builder.Services.AddScoped<IAccountCategoryRepository, AccountCategoryRepository>();
builder.Services.AddScoped<IAccountRepository, AccountRepository>();
builder.Services.AddScoped<IAccountAccessRepository, AccountAccessRepository>();
builder.Services.AddScoped<IAccountAccessInviteRepository, AccountAccessInviteRepository>();
builder.Services.AddScoped<IAccountAccountCategoryRepository, AccountAccountCategoryRepository>();
builder.Services.AddScoped<IItemCategoryRepository, ItemCategoryRepository>();
builder.Services.AddScoped<IItemRepository, ItemRepository>();
builder.Services.AddScoped<IItemGlobalRepository, ItemGlobalRepository>();
builder.Services.AddScoped<IMotionRepository, MotionRepository>();
}
private static void AddBusinessServices(WebApplicationBuilder builder)
{
builder.Services.AddScoped<UserService, UserService>();
builder.Services.AddScoped<CurrencyService, CurrencyService>();
builder.Services.AddScoped<AccountService, AccountService>();
builder.Services.AddScoped<ItemService, ItemService>();
builder.Services.AddScoped<DashboardService, DashboardService>();
}
private static readonly ConcurrentDictionary<string, PhysicalFileInfo> _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<GlobalSettings>();
var configuration = ctx.RequestServices.GetRequiredService<IConfiguration>();
var frontEndHost = configuration.GetValue<string>("FrontEnd:Host");
var isDynamicFrontEndHost = frontEndHost.IsMissing();
if (!isDynamicFrontEndHost)
{
globalSettings.Host = frontEndHost;
}
else
{
// set real frontend host
globalSettings.Host = GetFrontendHost(ctx, logger);
}
logger.LogInformation($"Is dynamic host: {isDynamicFrontEndHost}");
logger.LogInformation($"Init Host: {globalSettings.Host}");
// update identity server
var options = ctx.RequestServices.GetRequiredService<IdentityServerOptions>();
options.IssuerUri = globalSettings.Host;
// update identity server client redirect uri
var clients = ctx.RequestServices.GetRequiredService<IEnumerable<Client>>();
var client = clients.FirstOrDefault()!;
client.RedirectUris = new List<string>
{
$"{globalSettings.Host}/silent-refresh.html"
};
}
}
}
//
if (path.Value.EqualsIgnoreCase("/.well-known/openid-configuration"))
{
globalSettings ??= ctx.RequestServices.GetRequiredService<GlobalSettings>();
ctx.SetIdentityServerOrigin(globalSettings.Host);
var options = ctx.RequestServices.GetRequiredService<IdentityServerOptions>();
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<string>(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.UseAuthentication();
//app.UseRouting();
app.UseAuthorization();
MapRoutes(app);
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<string>(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;
}
}