From dd0c6e6f73bbf2c964148db2fa306053af444c6d Mon Sep 17 00:00:00 2001 From: tactile Date: Tue, 20 Jan 2026 17:25:34 +0500 Subject: [PATCH] Restore backend-dotnet --- backend-dotnet/.dockerignore | 6 + backend-dotnet/.gitignore | 12 + backend-dotnet/AppConfig.cs | 27 ++ backend-dotnet/Dockerfile | 18 ++ .../InsecureDevAuthenticationHandler.cs | 36 +++ backend-dotnet/Models.cs | 16 + backend-dotnet/Program.cs | 276 ++++++++++++++++++ backend-dotnet/README.md | 53 ++++ backend-dotnet/appsettings.Development.json | 30 ++ backend-dotnet/appsettings.Production.json | 30 ++ backend-dotnet/appsettings.json | 28 ++ backend-dotnet/backend-dotnet.csproj | 19 ++ 12 files changed, 551 insertions(+) create mode 100644 backend-dotnet/.dockerignore create mode 100644 backend-dotnet/.gitignore create mode 100644 backend-dotnet/AppConfig.cs create mode 100644 backend-dotnet/Dockerfile create mode 100644 backend-dotnet/InsecureDevAuthenticationHandler.cs create mode 100644 backend-dotnet/Models.cs create mode 100644 backend-dotnet/Program.cs create mode 100644 backend-dotnet/README.md create mode 100644 backend-dotnet/appsettings.Development.json create mode 100644 backend-dotnet/appsettings.Production.json create mode 100644 backend-dotnet/appsettings.json create mode 100644 backend-dotnet/backend-dotnet.csproj diff --git a/backend-dotnet/.dockerignore b/backend-dotnet/.dockerignore new file mode 100644 index 0000000..80f1ef6 --- /dev/null +++ b/backend-dotnet/.dockerignore @@ -0,0 +1,6 @@ +bin/ +obj/ +*.user +*.suo +.vs/ + diff --git a/backend-dotnet/.gitignore b/backend-dotnet/.gitignore new file mode 100644 index 0000000..d64c777 --- /dev/null +++ b/backend-dotnet/.gitignore @@ -0,0 +1,12 @@ +## .NET +bin/ +obj/ +*.user +*.suo +.vs/ +.vscode/ +*.swp +*.swo +*~ +.DS_Store + diff --git a/backend-dotnet/AppConfig.cs b/backend-dotnet/AppConfig.cs new file mode 100644 index 0000000..a1fec85 --- /dev/null +++ b/backend-dotnet/AppConfig.cs @@ -0,0 +1,27 @@ +namespace DexDemoBackend; + +public class AppConfig +{ + public string DbHost { get; set; } = "postgres"; + public string DbPort { get; set; } = "5440"; + public string DbName { get; set; } = "dexdemo"; + public string DbUser { get; set; } = "dexdemo"; + public string DbPassword { get; set; } = "dexdemo"; + public string Issuer { get; set; } = "https://dex.127.0.0.1.sslip.io/"; + public bool InsecureDevMode { get; set; } + public string? InsecureDevEmail { get; set; } + public string[] AllowedOrigins { get; set; } = ["http://localhost:3000", "https://localhost:3000"]; + + // JWT configuration + public string NameClaimType { get; set; } = "name"; + public string RoleClaimType { get; set; } = "role"; + public string EmailClaimType { get; set; } = "email"; + public string AuthRequestEmailHeader { get; set; } = "X-Auth-Request-Email"; + public TimeSpan ClockSkew { get; set; } = TimeSpan.FromMinutes(5); + + public string ConnectionString => + $"Host={DbHost};Port={DbPort};Database={DbName};Username={DbUser};Password={DbPassword}"; +} + + + diff --git a/backend-dotnet/Dockerfile b/backend-dotnet/Dockerfile new file mode 100644 index 0000000..a32c594 --- /dev/null +++ b/backend-dotnet/Dockerfile @@ -0,0 +1,18 @@ +FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build +WORKDIR /src + +COPY *.csproj . +RUN dotnet restore + +COPY . . +RUN dotnet publish -c Release -o /app/publish + +FROM mcr.microsoft.com/dotnet/aspnet:8.0 +WORKDIR /app +COPY --from=build /app/publish . + +EXPOSE 8000 +ENV ASPNETCORE_URLS=http://+:8000 + +ENTRYPOINT ["dotnet", "backend-dotnet.dll"] + diff --git a/backend-dotnet/InsecureDevAuthenticationHandler.cs b/backend-dotnet/InsecureDevAuthenticationHandler.cs new file mode 100644 index 0000000..3c18fcf --- /dev/null +++ b/backend-dotnet/InsecureDevAuthenticationHandler.cs @@ -0,0 +1,36 @@ +using DexDemoBackend; +using Microsoft.AspNetCore.Authentication; +using System.Security.Claims; +using Microsoft.Extensions.Options; +using System.Text.Encodings.Web; + +public class InsecureDevAuthenticationHandler : AuthenticationHandler +{ + private readonly AppConfig _config; + + public InsecureDevAuthenticationHandler( + IOptionsMonitor options, + ILoggerFactory logger, + UrlEncoder encoder, + AppConfig config) : base(options, logger, encoder) + { + _config = config; + } + + protected override Task HandleAuthenticateAsync() + { + var email = _config.InsecureDevEmail ?? "dev@example.com"; + + var claims = new[] + { + new Claim(ClaimTypes.Name, email), + new Claim(_config.EmailClaimType, email) + }; + + var identity = new ClaimsIdentity(claims, Scheme.Name); + var principal = new ClaimsPrincipal(identity); + var ticket = new AuthenticationTicket(principal, Scheme.Name); + + return Task.FromResult(AuthenticateResult.Success(ticket)); + } +} diff --git a/backend-dotnet/Models.cs b/backend-dotnet/Models.cs new file mode 100644 index 0000000..5f08a1d --- /dev/null +++ b/backend-dotnet/Models.cs @@ -0,0 +1,16 @@ +namespace DexDemoBackend; + +public record Organization(int Id, string Name); + +public record Role(int Id, string Name, string? Description); + +public record Link(int Id, string Title, string Url, string? Description); + +public record UserInfo( + string Email, + string FullName, + Organization? Organization, + List Roles, + List AvailableLinks +); + diff --git a/backend-dotnet/Program.cs b/backend-dotnet/Program.cs new file mode 100644 index 0000000..8630cfd --- /dev/null +++ b/backend-dotnet/Program.cs @@ -0,0 +1,276 @@ +using Dapper; +using DexDemoBackend; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.IdentityModel.Protocols.OpenIdConnect; +using Microsoft.IdentityModel.Protocols; +using Microsoft.IdentityModel.Tokens; +using Npgsql; +using System.Security.Claims; +using Microsoft.Extensions.Logging; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.Configure(builder.Configuration.GetSection("AppConfig")); +builder.Services.AddSingleton(sp => sp.GetRequiredService>().Value); + +var config = builder.Configuration.GetSection("AppConfig").Get() ?? new AppConfig(); + +ValidateConfiguration(config); + +ConfigureJwtAuthentication(builder.Services, config); + +builder.Services.AddCors(options => +{ + options.AddDefaultPolicy(policy => + { + policy.WithOrigins(config.AllowedOrigins) + .AllowAnyMethod() + .AllowAnyHeader() + .AllowCredentials(); + }); +}); + +builder.Services.AddAuthorization(); + +builder.Services.ConfigureHttpJsonOptions(options => +{ + options.SerializerOptions.PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.SnakeCaseLower; +}); + +var app = builder.Build(); +Dapper.DefaultTypeMap.MatchNamesWithUnderscores = true; + +app.UseCors(); +app.UseAuthentication(); +app.UseAuthorization(); + +app.Use(async (context, next) => +{ + try + { + await next(); + } + catch (UnauthorizedAccessException) + { + context.Response.StatusCode = StatusCodes.Status401Unauthorized; + await context.Response.WriteAsJsonAsync(new { error = "Unauthorized" }); + } + catch (Exception) + { + context.Response.StatusCode = StatusCodes.Status500InternalServerError; + await context.Response.WriteAsJsonAsync(new { error = "Internal server error" }); + } +}); + +app.MapGet("/api/health", () => Results.Ok(new HealthResponse("ok"))); + +app.MapGet("/api/user-identity", [Authorize] (HttpContext context) => +{ + return Results.Ok(new UserIdentityResponse( + context.User.Identity?.Name, + context.User.Identity?.IsAuthenticated ?? false, + context.User.Identity?.AuthenticationType, + context.User.Claims.Select(c => new ClaimResponse(c.Type, c.Value)).ToList() + )); +}); + +app.MapGet("/api/user-info", [Authorize] async (HttpContext context, [FromServices] AppConfig cfg) => +{ + try + { + var email = await GetUserEmail(context, cfg); + + await using var conn = new NpgsqlConnection(cfg.ConnectionString); + await conn.OpenAsync(); + + var userData = await conn.QueryAsync(@" + SELECT + u.email, u.full_name, + o.id as org_id, o.name as org_name, + r.id as role_id, r.name as role_name, r.description as role_description, + l.id as link_id, l.title as link_title, l.url as link_url, l.description as link_description + FROM users u + LEFT JOIN organizations o ON u.organization_id = o.id + LEFT JOIN user_roles ur ON u.id = ur.user_id + LEFT JOIN roles r ON ur.role_id = r.id + LEFT JOIN role_links rl ON r.id = rl.role_id + LEFT JOIN links l ON rl.link_id = l.id + WHERE u.email = @email + ORDER BY l.id", + new { email }); + + if (!userData.Any()) + { + return Results.NotFound(new { detail = "User not found in database" }); + } + + var firstRecord = userData.First(); + var organization = firstRecord.OrgId.HasValue && firstRecord.OrgName != null + ? new Organization(firstRecord.OrgId.Value, firstRecord.OrgName) + : null; + + var roles = userData + .Where(x => x.RoleId.HasValue) + .GroupBy(x => x.RoleId) + .Select(g => g.First()) + .Select(x => new Role(x.RoleId!.Value, x.RoleName!, x.RoleDescription)) + .ToList(); + + var links = userData + .Where(x => x.LinkId.HasValue) + .GroupBy(x => x.LinkId) + .Select(g => g.First()) + .Select(x => new Link(x.LinkId!.Value, x.LinkTitle!, x.LinkUrl!, x.LinkDescription)) + .ToList(); + + return Results.Ok(new UserInfo(firstRecord.Email, firstRecord.FullName, organization, roles, links)); + } + catch (NpgsqlException ex) when (ex.IsTransient) + { + return Results.Json(new { error = "Database temporarily unavailable" }, statusCode: 503); + } + catch (NpgsqlException) + { + return Results.Json(new { error = "Database error" }, statusCode: 500); + } +}); + +app.Run(); + +static void ConfigureJwtAuthentication(IServiceCollection services, AppConfig config) +{ + if (config.InsecureDevMode) + { + services.AddAuthentication("InsecureDev") + .AddScheme("InsecureDev", _ => { }); + + return; + } + + services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddJwtBearer(options => + { + options.Authority = config.Issuer; + options.RequireHttpsMetadata = false; + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidIssuer = config.Issuer, + ValidateAudience = false, + ValidateLifetime = true, + ValidateIssuerSigningKey = true, + ClockSkew = config.ClockSkew, + NameClaimType = config.NameClaimType, + RoleClaimType = config.RoleClaimType + }; + + options.Events = new JwtBearerEvents + { + OnTokenValidated = context => + { + var nameClaim = context.Principal?.FindFirst(config.NameClaimType); + var emailClaim = context.Principal?.FindFirst(config.EmailClaimType); + + if (nameClaim == null && emailClaim != null) + { + var identity = context.Principal?.Identity as ClaimsIdentity; + identity?.AddClaim(new Claim(config.NameClaimType, emailClaim.Value)); + } + + return Task.CompletedTask; + } + }; + + var httpClientHandler = new HttpClientHandler + { + ServerCertificateCustomValidationCallback = (_, _, _, _) => true + }; + + var discoveryUrl = $"{config.Issuer}.well-known/openid-configuration"; + + options.ConfigurationManager = new ConfigurationManager( + discoveryUrl, + new OpenIdConnectConfigurationRetriever(), + new HttpDocumentRetriever(new HttpClient(httpClientHandler)) + { + RequireHttps = !config.InsecureDevMode + } + ); + }); +} + + +static void ValidateConfiguration(AppConfig config) +{ + if (string.IsNullOrWhiteSpace(config.DbHost)) + throw new InvalidOperationException("Configuration parameter 'AppConfig:DbHost' is required"); + + if (string.IsNullOrWhiteSpace(config.DbPort)) + throw new InvalidOperationException("Configuration parameter 'AppConfig:DbPort' is required"); + + if (string.IsNullOrWhiteSpace(config.DbName)) + throw new InvalidOperationException("Configuration parameter 'AppConfig:DbName' is required"); + + if (string.IsNullOrWhiteSpace(config.DbUser)) + throw new InvalidOperationException("Configuration parameter 'AppConfig:DbUser' is required"); + + if (string.IsNullOrWhiteSpace(config.DbPassword)) + throw new InvalidOperationException("Configuration parameter 'AppConfig:DbPassword' is required"); + + if (string.IsNullOrWhiteSpace(config.Issuer)) + throw new InvalidOperationException("Configuration parameter 'AppConfig:Issuer' is required"); + + if (!Uri.TryCreate(config.Issuer, UriKind.Absolute, out _)) + throw new InvalidOperationException("Configuration parameter 'AppConfig:Issuer' must be a valid URL"); + + if (config.InsecureDevMode && string.IsNullOrWhiteSpace(config.InsecureDevEmail)) + throw new InvalidOperationException("Configuration parameter 'AppConfig:InsecureDevEmail' is required when 'AppConfig:InsecureDevMode' is enabled"); +} + +static Task GetUserEmail(HttpContext context, AppConfig config) +{ + if (context.User.Identity?.IsAuthenticated == true) + { + var emailClaim = context.User.FindFirst(config.EmailClaimType); + if (emailClaim != null) + { + return Task.FromResult(emailClaim.Value); + } + } + + if (context.Request.Headers.TryGetValue(config.AuthRequestEmailHeader, out var emailHeader)) + { + return Task.FromResult(emailHeader.ToString()); + } + + throw new UnauthorizedAccessException("No authentication information found"); +} + +record UserDataResult( + string Email, + string FullName, + int? OrgId, + string? OrgName, + int? RoleId, + string? RoleName, + string? RoleDescription, + int? LinkId, + string? LinkTitle, + string? LinkUrl, + string? LinkDescription +); + +// API Response Models +record HealthResponse(string Status); + +record ClaimResponse(string Type, string Value); + +record UserIdentityResponse( + string? Name, + bool IsAuthenticated, + string? AuthenticationType, + List Claims +); diff --git a/backend-dotnet/README.md b/backend-dotnet/README.md new file mode 100644 index 0000000..8e480be --- /dev/null +++ b/backend-dotnet/README.md @@ -0,0 +1,53 @@ +# .NET 8 Backend + +Функционально эквивалентный бэкенд на .NET 8 для Dex Demo. + +## Запуск + +### Локально +```bash +dotnet run +``` + +### Docker +```bash +docker build -t dex-demo-backend-dotnet:latest . +docker run -p 8000:8000 \ + -e AppConfig__DbHost=postgres \ + -e AppConfig__DbPort=5440 \ + -e AppConfig__Issuer=https://dex.127.0.0.1.sslip.io/ \ + dex-demo-backend-dotnet:latest +``` + +## Особенности реализации + +- **Minimal APIs**: современный подход ASP.NET Core без лишнего бойлерплейта +- **Records**: для моделей данных (immutable, concise) +- **Dapper**: микро-ORM для чистой и производительной работы с БД +- **Npgsql**: официальный PostgreSQL provider для .NET +- **OpenIdConnect**: стандартный механизм получения JWKS + +## API + +- `GET /api/health` - проверка здоровья +- `GET /api/user-info` - информация о пользователе (требует авторизацию) + +## Переменные окружения + +### База данных +- `AppConfig__DbHost` - хост PostgreSQL (по умолчанию: postgres) +- `AppConfig__DbPort` - порт PostgreSQL (по умолчанию: 5440) +- `AppConfig__DbName` - имя базы данных (по умолчанию: dexdemo) +- `AppConfig__DbUser` - пользователь базы данных (по умолчанию: dexdemo) +- `AppConfig__DbPassword` - пароль базы данных (по умолчанию: dexdemo) + +### Аутентификация +- `AppConfig__Issuer` - URL Dex сервера (по умолчанию: https://dex.127.0.0.1.sslip.io/) + +### Режим разработки +- `AppConfig__InsecureDevMode` - включить небезопасный режим разработки (true/false) +- `AppConfig__InsecureDevEmail` - email для тестирования в режиме разработки + +### CORS +- `AppConfig__AllowedOrigins` - разрешенные origins для CORS (JSON массив) + diff --git a/backend-dotnet/appsettings.Development.json b/backend-dotnet/appsettings.Development.json new file mode 100644 index 0000000..3900e2e --- /dev/null +++ b/backend-dotnet/appsettings.Development.json @@ -0,0 +1,30 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Debug", + "Microsoft.AspNetCore.Authentication": "Trace", + "Microsoft.AspNetCore.Authorization": "Trace", + "Microsoft.IdentityModel": "Trace", + "System.Net.Http.HttpClient": "Trace", + "Microsoft.AspNetCore.Authentication.JwtBearer": "Trace", + "Microsoft.AspNetCore.Authentication.JwtBearer.JwtBearerHandler": "Trace", + "Microsoft.AspNetCore.Authorization.AuthorizationMiddleware": "Trace", + "Microsoft.AspNetCore.Authorization.DefaultAuthorizationService": "Trace", + "Microsoft.Hosting.Lifetime": "Information", + "Microsoft.AspNetCore": "Information" + } + }, + "Urls": "http://localhost:8000", + "AppConfig": { + "DbHost": "localhost", + "DbPort": "5432", + "DbName": "dexdemo", + "DbUser": "dexdemo", + "DbPassword": "dexdemo", + "Issuer": "https://dex.127.0.0.1.sslip.io/", + "InsecureDevMode": true, + "InsecureDevEmail": "test@example.com", + "AllowedOrigins": ["http://localhost:3000", "https://localhost:3000", "http://localhost:5173", "https://localhost:5173"] + } +} + diff --git a/backend-dotnet/appsettings.Production.json b/backend-dotnet/appsettings.Production.json new file mode 100644 index 0000000..df7eb3a --- /dev/null +++ b/backend-dotnet/appsettings.Production.json @@ -0,0 +1,30 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore.Authentication": "Warning", + "Microsoft.AspNetCore.Authorization": "Warning", + "Microsoft.IdentityModel": "Warning", + "System.Net.Http.HttpClient": "Warning", + "Microsoft.AspNetCore.Authentication.JwtBearer": "Information", + "Microsoft.AspNetCore.Authentication.JwtBearer.JwtBearerHandler": "Information", + "Microsoft.AspNetCore.Authorization.AuthorizationMiddleware": "Information", + "Microsoft.AspNetCore.Authorization.DefaultAuthorizationService": "Information", + "Microsoft.Hosting.Lifetime": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "AppConfig": { + "DbHost": "postgres", + "DbPort": "5432", + "DbName": "dexdemo", + "DbUser": "dexdemo", + "DbPassword": "dexdemo", + "Issuer": "https://dex.127.0.0.1.sslip.io/", + "InsecureDevMode": false, + "AllowedOrigins": ["https://yourdomain.com"] + } +} + + diff --git a/backend-dotnet/appsettings.json b/backend-dotnet/appsettings.json new file mode 100644 index 0000000..a4b9390 --- /dev/null +++ b/backend-dotnet/appsettings.json @@ -0,0 +1,28 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Debug", + "Microsoft.AspNetCore.Authentication": "Debug", + "Microsoft.AspNetCore.Authorization": "Debug", + "Microsoft.IdentityModel": "Debug", + "System.Net.Http.HttpClient": "Debug", + "Microsoft.AspNetCore.Authentication.JwtBearer": "Debug", + "Microsoft.AspNetCore.Authentication.JwtBearer.JwtBearerHandler": "Debug", + "Microsoft.AspNetCore.Authorization.AuthorizationMiddleware": "Debug", + "Microsoft.AspNetCore.Authorization.DefaultAuthorizationService": "Debug", + "Microsoft.Hosting.Lifetime": "Information" + } + }, + "AllowedHosts": "*", + "AppConfig": { + "DbHost": "postgres", + "DbPort": "5440", + "DbName": "dexdemo", + "DbUser": "dexdemo", + "DbPassword": "dexdemo", + "Issuer": "https://dex.127.0.0.1.sslip.io/", + "InsecureDevMode": false, + "AllowedOrigins": ["http://localhost:3000", "https://localhost:3000"] + } +} + diff --git a/backend-dotnet/backend-dotnet.csproj b/backend-dotnet/backend-dotnet.csproj new file mode 100644 index 0000000..eb36e33 --- /dev/null +++ b/backend-dotnet/backend-dotnet.csproj @@ -0,0 +1,19 @@ + + + + net8.0 + enable + enable + DexDemoBackend + + + + + + + + + + + +