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