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..3f3dcda --- /dev/null +++ b/backend-dotnet/AppConfig.cs @@ -0,0 +1,17 @@ +namespace DexDemoBackend; + +public class AppConfig +{ + public string DbHost { get; init; } = default!; + public string DbPort { get; init; } = default!; + public string DbName { get; init; } = default!; + public string DbUser { get; init; } = default!; + public string DbPassword { get; init; } = default!; + public string DexIssuer { get; init; } = default!; + public bool InsecureDevMode { get; init; } + public string? InsecureDevEmail { get; init; } + + 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/JwtValidator.cs b/backend-dotnet/JwtValidator.cs new file mode 100644 index 0000000..adbe929 --- /dev/null +++ b/backend-dotnet/JwtValidator.cs @@ -0,0 +1,58 @@ +using System.IdentityModel.Tokens.Jwt; +using Microsoft.IdentityModel.Protocols; +using Microsoft.IdentityModel.Protocols.OpenIdConnect; +using Microsoft.IdentityModel.Tokens; + +namespace DexDemoBackend; + +public class JwtValidator +{ + private readonly AppConfig _config; + private readonly HttpClient _httpClient; + private ConfigurationManager? _configManager; + + public JwtValidator(AppConfig config) + { + _config = config; + _httpClient = new HttpClient(new HttpClientHandler + { + ServerCertificateCustomValidationCallback = (_, _, _, _) => true + }); + } + + public async Task> ValidateToken(string token) + { + try + { + // Lazy init OIDC configuration manager + _configManager ??= new ConfigurationManager( + $"{_config.DexIssuer}.well-known/openid-configuration", + new OpenIdConnectConfigurationRetriever(), + new HttpDocumentRetriever(_httpClient) { RequireHttps = false } + ); + + var oidcConfig = await _configManager.GetConfigurationAsync(CancellationToken.None); + + var validationParameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidIssuer = _config.DexIssuer, + ValidateAudience = false, + ValidateLifetime = true, + ValidateIssuerSigningKey = true, + IssuerSigningKeys = oidcConfig.SigningKeys + }; + + var handler = new JwtSecurityTokenHandler(); + var principal = handler.ValidateToken(token, validationParameters, out _); + + var jwtToken = handler.ReadJwtToken(token); + return jwtToken.Payload; + } + catch (Exception ex) + { + throw new UnauthorizedAccessException($"Token validation error: {ex.Message}", ex); + } + } +} + 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..af69e8e --- /dev/null +++ b/backend-dotnet/Program.cs @@ -0,0 +1,133 @@ +using Dapper; +using DexDemoBackend; +using Microsoft.AspNetCore.Mvc; +using Npgsql; + +var builder = WebApplication.CreateBuilder(args); + +// Configuration +var config = new AppConfig +{ + DbHost = Environment.GetEnvironmentVariable("DB_HOST") ?? "postgres", + DbPort = Environment.GetEnvironmentVariable("DB_PORT") ?? "5440", + DbName = Environment.GetEnvironmentVariable("DB_NAME") ?? "dexdemo", + DbUser = Environment.GetEnvironmentVariable("DB_USER") ?? "dexdemo", + DbPassword = Environment.GetEnvironmentVariable("DB_PASSWORD") ?? "dexdemo", + DexIssuer = Environment.GetEnvironmentVariable("DEX_ISSUER") ?? "https://dex.127.0.0.1.sslip.io/", + InsecureDevMode = Environment.GetEnvironmentVariable("INSECURE_DEV_MODE")?.ToLower() == "true", + InsecureDevEmail = Environment.GetEnvironmentVariable("INSECURE_DEV_EMAIL") +}; + +builder.Services.AddSingleton(config); +builder.Services.AddSingleton(); + +// Configure JSON serialization to use snake_case +builder.Services.ConfigureHttpJsonOptions(options => +{ + options.SerializerOptions.PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.SnakeCaseLower; +}); + +builder.Services.AddCors(options => +{ + options.AddDefaultPolicy(policy => + { + policy.AllowAnyOrigin() + .AllowAnyMethod() + .AllowAnyHeader(); + }); +}); + +var app = builder.Build(); +Dapper.DefaultTypeMap.MatchNamesWithUnderscores = true; + +app.UseCors(); + +// Health check endpoint +app.MapGet("/api/health", () => Results.Ok(new { status = "ok" })); + +// User info endpoint +app.MapGet("/api/user-info", async (HttpContext context, [FromServices] AppConfig cfg, [FromServices] JwtValidator jwtValidator) => +{ + var email = await GetUserEmail(context, cfg, jwtValidator); + + await using var conn = new NpgsqlConnection(cfg.ConnectionString); + await conn.OpenAsync(); + + // Get user info + var userResult = await conn.QuerySingleOrDefaultAsync(@" + SELECT u.email, u.full_name, u.organization_id, o.name as org_name + FROM users u + LEFT JOIN organizations o ON u.organization_id = o.id + WHERE u.email = @email", + new { email }); + + if (userResult is null) + { + return Results.NotFound(new { detail = "User not found in database" }); + } + + var organization = userResult.OrganizationId.HasValue && userResult.OrgName != null + ? new Organization(userResult.OrganizationId.Value, userResult.OrgName) + : null; + + // Get user roles + var roles = (await conn.QueryAsync(@" + SELECT r.id, r.name, r.description + FROM roles r + JOIN user_roles ur ON r.id = ur.role_id + JOIN users u ON ur.user_id = u.id + WHERE u.email = @email", + new { email })).ToList(); + + // Get available links + var links = (await conn.QueryAsync(@" + SELECT DISTINCT l.id, l.title, l.url, l.description + FROM links l + JOIN role_links rl ON l.id = rl.link_id + JOIN user_roles ur ON rl.role_id = ur.role_id + JOIN users u ON ur.user_id = u.id + WHERE u.email = @email + ORDER BY l.id", + new { email })).ToList(); + + return Results.Ok(new UserInfo(userResult.Email, userResult.FullName, organization, roles, links)); +}); + +app.Run(); + +static async Task GetUserEmail(HttpContext context, AppConfig config, JwtValidator jwtValidator) +{ + // Dev mode + if (config.InsecureDevMode) + { + Console.WriteLine($"INSECURE_DEV_MODE: Using email {config.InsecureDevEmail}"); + return config.InsecureDevEmail!; + } + + // Try Authorization header + if (context.Request.Headers.TryGetValue("Authorization", out var authHeader)) + { + var headerValue = authHeader.ToString(); + if (headerValue.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) + { + var token = headerValue[7..]; + var payload = await jwtValidator.ValidateToken(token); + + if (payload.TryGetValue("email", out var emailClaim)) + { + return emailClaim.ToString()!; + } + } + } + + // Try OAuth2 proxy header + if (context.Request.Headers.TryGetValue("X-Auth-Request-Email", out var emailHeader)) + { + return emailHeader.ToString(); + } + + throw new UnauthorizedAccessException("No authentication information found"); +} + +// Internal query result model +record UserQueryResult(string Email, string FullName, int? OrganizationId, string? OrgName); \ No newline at end of file diff --git a/backend-dotnet/README.md b/backend-dotnet/README.md new file mode 100644 index 0000000..f796267 --- /dev/null +++ b/backend-dotnet/README.md @@ -0,0 +1,44 @@ +# .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 DB_HOST=postgres \ + -e DB_PORT=5440 \ + -e DEX_ISSUER=https://dex.127.0.0.1.sslip.io/ \ + dex-demo-backend-dotnet:latest +``` + +## Особенности реализации + +- **Minimal APIs**: современный подход ASP.NET Core без лишнего бойлерплейта +- **Records**: для моделей данных (immutable, concise) +- **Dapper**: микро-ORM для чистой и производительной работы с БД +- **Dommel**: расширение Dapper для CRUD операций +- **OpenIdConnect**: стандартный механизм получения JWKS +- **Npgsql**: официальный PostgreSQL provider для .NET +- **Async/await**: полностью асинхронный код +- **Top-level statements**: без Program/Main класса + +## API + +- `GET /api/health` - проверка здоровья +- `GET /api/user-info` - информация о пользователе (требует авторизацию) + +## Переменные окружения + +Все переменные идентичны Python версии: +- `DB_HOST`, `DB_PORT`, `DB_NAME`, `DB_USER`, `DB_PASSWORD` +- `DEX_ISSUER` +- `INSECURE_DEV_MODE`, `INSECURE_DEV_EMAIL` + diff --git a/backend-dotnet/appsettings.Development.json b/backend-dotnet/appsettings.Development.json new file mode 100644 index 0000000..6dcdaf1 --- /dev/null +++ b/backend-dotnet/appsettings.Development.json @@ -0,0 +1,10 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "Urls": "http://localhost:8000" +} + diff --git a/backend-dotnet/appsettings.json b/backend-dotnet/appsettings.json new file mode 100644 index 0000000..3a4a202 --- /dev/null +++ b/backend-dotnet/appsettings.json @@ -0,0 +1,11 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "Urls": "http://localhost:8000" +} + diff --git a/backend-dotnet/backend-dotnet.csproj b/backend-dotnet/backend-dotnet.csproj new file mode 100644 index 0000000..f831454 --- /dev/null +++ b/backend-dotnet/backend-dotnet.csproj @@ -0,0 +1,18 @@ + + + + net8.0 + enable + enable + DexDemoBackend + + + + + + + + + + + diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 3b39090..12e5780 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -56,11 +56,11 @@ function App() {
Email: - {userInfo.email} + {userInfo.email || 'Не указан'}
Полное имя: - {userInfo.full_name} + {userInfo.full_name || 'Не указано'}
{userInfo.organization && (
@@ -73,7 +73,7 @@ function App() {

Роли

- {userInfo.roles.map(role => ( + {userInfo.roles && userInfo.roles.map(role => (
{role.name} {role.description && ( @@ -86,7 +86,7 @@ function App() {

Доступные ресурсы

- {userInfo.available_links.length > 0 ? ( + {userInfo.available_links && userInfo.available_links.length > 0 ? (
{userInfo.available_links.map(link => (