add dotnet backend
This commit is contained in:
6
backend-dotnet/.dockerignore
Normal file
6
backend-dotnet/.dockerignore
Normal file
@ -0,0 +1,6 @@
|
||||
bin/
|
||||
obj/
|
||||
*.user
|
||||
*.suo
|
||||
.vs/
|
||||
|
||||
12
backend-dotnet/.gitignore
vendored
Normal file
12
backend-dotnet/.gitignore
vendored
Normal file
@ -0,0 +1,12 @@
|
||||
## .NET
|
||||
bin/
|
||||
obj/
|
||||
*.user
|
||||
*.suo
|
||||
.vs/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
.DS_Store
|
||||
|
||||
17
backend-dotnet/AppConfig.cs
Normal file
17
backend-dotnet/AppConfig.cs
Normal 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
18
backend-dotnet/Dockerfile
Normal 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"]
|
||||
|
||||
58
backend-dotnet/JwtValidator.cs
Normal file
58
backend-dotnet/JwtValidator.cs
Normal 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
16
backend-dotnet/Models.cs
Normal 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
133
backend-dotnet/Program.cs
Normal 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
44
backend-dotnet/README.md
Normal 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`
|
||||
|
||||
10
backend-dotnet/appsettings.Development.json
Normal file
10
backend-dotnet/appsettings.Development.json
Normal file
@ -0,0 +1,10 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"Urls": "http://localhost:8000"
|
||||
}
|
||||
|
||||
11
backend-dotnet/appsettings.json
Normal file
11
backend-dotnet/appsettings.json
Normal file
@ -0,0 +1,11 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*",
|
||||
"Urls": "http://localhost:8000"
|
||||
}
|
||||
|
||||
18
backend-dotnet/backend-dotnet.csproj
Normal file
18
backend-dotnet/backend-dotnet.csproj
Normal 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>
|
||||
|
||||
@ -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
|
||||
|
||||
Reference in New Issue
Block a user