add dotnet backend

This commit is contained in:
2025-10-02 15:19:15 +05:00
parent 67f292cd60
commit 109629c6f9
12 changed files with 347 additions and 4 deletions

View File

@ -0,0 +1,6 @@
bin/
obj/
*.user
*.suo
.vs/

12
backend-dotnet/.gitignore vendored Normal file
View File

@ -0,0 +1,12 @@
## .NET
bin/
obj/
*.user
*.suo
.vs/
.vscode/
*.swp
*.swo
*~
.DS_Store

View File

@ -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}";
}

18
backend-dotnet/Dockerfile Normal file
View File

@ -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"]

View File

@ -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<OpenIdConnectConfiguration>? _configManager;
public JwtValidator(AppConfig config)
{
_config = config;
_httpClient = new HttpClient(new HttpClientHandler
{
ServerCertificateCustomValidationCallback = (_, _, _, _) => true
});
}
public async Task<IDictionary<string, object>> ValidateToken(string token)
{
try
{
// Lazy init OIDC configuration manager
_configManager ??= new ConfigurationManager<OpenIdConnectConfiguration>(
$"{_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);
}
}
}

16
backend-dotnet/Models.cs Normal file
View File

@ -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<Role> Roles,
List<Link> AvailableLinks
);

133
backend-dotnet/Program.cs Normal file
View File

@ -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<JwtValidator>();
// 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<UserQueryResult>(@"
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<Role>(@"
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<Link>(@"
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<string> 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);

44
backend-dotnet/README.md Normal file
View File

@ -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`

View File

@ -0,0 +1,10 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"Urls": "http://localhost:8000"
}

View File

@ -0,0 +1,11 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
"Urls": "http://localhost:8000"
}

View File

@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>DexDemoBackend</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Dapper" Version="2.1.66" />
<PackageReference Include="Microsoft.IdentityModel.Protocols.OpenIdConnect" Version="8.14.0" />
<PackageReference Include="Npgsql" Version="8.0.7" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.14.0" />
</ItemGroup>
</Project>

View File

@ -56,11 +56,11 @@ function App() {
<div className="info-grid">
<div className="info-item">
<span className="label">Email:</span>
<span className="value">{userInfo.email}</span>
<span className="value">{userInfo.email || 'Не указан'}</span>
</div>
<div className="info-item">
<span className="label">Полное имя:</span>
<span className="value">{userInfo.full_name}</span>
<span className="value">{userInfo.full_name || 'Не указано'}</span>
</div>
{userInfo.organization && (
<div className="info-item">
@ -73,7 +73,7 @@ function App() {
<div className="roles-section">
<h3>Роли</h3>
<div className="roles-list">
{userInfo.roles.map(role => (
{userInfo.roles && userInfo.roles.map(role => (
<div key={role.id} className="role-badge">
<span className="role-name">{role.name}</span>
{role.description && (
@ -86,7 +86,7 @@ function App() {
<div className="links-section">
<h3>Доступные ресурсы</h3>
{userInfo.available_links.length > 0 ? (
{userInfo.available_links && userInfo.available_links.length > 0 ? (
<div className="links-grid">
{userInfo.available_links.map(link => (
<a