Compare commits

..

7 Commits

29 changed files with 3880 additions and 1807 deletions

1
.gitignore vendored
View File

@@ -35,4 +35,5 @@ yarn-error.log*
# Kubernetes
*.local.yaml
*.zip

137
README.md
View File

@@ -1,37 +1,25 @@
# Dex Demo Application
Демонстрационное приложение для аутентификации через DexAuthenticator в Kubernetes кластере с Deckhouse.
Демонстрационное приложение для аутентификации через DexAuthenticator в Kubernetes (Deckhouse).
![preview](preview.png)
## Описание
Простое приложение, демонстрирующее интеграцию с DexAuthenticator:
- **Backend (Python/FastAPI)**: Валидирует JWT токены, получает данные пользователя из PostgreSQL
- **Frontend (React/Vite)**: Отображает информацию о пользователе и доступные ресурсы на основе ролей
- **PostgreSQL**: Хранит пользователей, роли и доступные ссылки
- **DexAuthenticator**: Обеспечивает аутентификацию через Dex
- **Backend (Python/FastAPI)**: валидирует JWT, берет данные пользователя и роли из PostgreSQL
- **Backend (.NET)**: функционально идентичен Python backend
- **Frontend (React/Vite)**: отображает информацию о пользователе и доступные ресурсы
- **PostgreSQL**: хранит пользователей, роли и ссылки
- **DexAuthenticator**: обеспечивает аутентификацию через Dex
## Работа приложения
1. Пользователь открывает `https://python-navigator-demo.127.0.0.1.sslip.io`
2. Ingress перенаправляет на DexAuthenticator для аутентификации
3. Если не аутентифицирован происходит редирект на Dex (HTTP 302) для входа
4. Если не аутентифицирован в Dex происходит редирект на Blitz IdP (HTTP 302) для входа
5. После аутентификации в Blitz IdP → возврат в Dex
6. После успешной аутентификации Dex возвращает токен в DexAuthenticator
7. DexAuthenticator устанавливает заголовки (`X-Auth-Request-Email`,`X-Auth-Request-User`, `Authorization`) и cookie
8. После успешной аутентификации Frontend загружается
9. Frontend делает запрос к `/api/user-info`
10. DexAuthenticator устанавливает заголовки (`X-Auth-Request-Email`,`X-Auth-Request-User`, `Authorization`) и cookie
11. Backend:
- Валидирует JWT токен из заголовка `Authorization`
- Извлекает email/id пользователя
- Получает данные из PostgreSQL
- Возвращает информацию о пользователе и доступных ресурсах
12. Frontend отображает информацию о пользователе и доступные ресурсы
1. Ingress/DexAuthenticator выполняет аутентификацию через Dex/IdP
2. DexAuthenticator проксирует запросы и добавляет заголовки `X-Auth-Request-Email`, `X-Auth-Request-User`, `Authorization`
3. Frontend запрашивает `/api/user-info`
4. Backend валидирует JWT, получает данные из PostgreSQL и возвращает информацию о пользователе и доступных ресурсах
![Диаграмма последовательности](sequenceDiagram.png)
## Архитектура
@@ -58,10 +46,8 @@
## Предварительные требования
- Kubernetes кластер с Deckhouse
- Настроенный Dex по адресу `https://dex.127.0.0.1.sslip.io`
- Docker
- kubectl
- make
- Dex по адресу `https://dex.127.0.0.1.sslip.io`
- Docker, kubectl, make
## Быстрый старт
@@ -124,61 +110,23 @@ make clean
```
.
├── backend/ # Python бэкенд
│ ├── main.py # FastAPI приложение
│ ├── requirements.txt # Python зависимости
│ └── Dockerfile # Docker образ
├── frontend/ # React фронтенд
│ ├── src/
│ │ ├── App.jsx # Главный компонент
│ │ └── App.css # Стили
│ ├── nginx.conf # Nginx конфигурация
│ └── Dockerfile # Docker образ
├── db/ # База данных
│ └── init.sql # SQL скрипт инициализации
├── k8s/ # Kubernetes манифесты
│ ├── namespace.yaml
│ ├── postgres.yaml
│ ├── backend.yaml
│ ├── frontend.yaml
│ ├── dex-authenticator.yaml
│ └── ingress.yaml
├── Makefile # Команды для сборки и развертывания
└── README.md # Документация
├── backend/ # Python backend (FastAPI)
├── backend-dotnet/ # .NET backend (идентичен по логике)
├── frontend/ # React frontend
├── db/ # PostgreSQL init.sql
├── k8s/ # Kubernetes манифесты
├── Makefile
└── README.md
```
## Компоненты
### Backend (FastAPI)
### Backend (Python/.NET)
**Эндпоинты:**
- `GET /api/health` - Проверка здоровья сервиса
- `GET /api/user-info` - Получение информации о пользователе
**Функциональность:**
- Валидация JWT токенов от Dex (проверка подписи, issuer, exp)
- Извлечение email пользователя из токена или заголовков
- Получение данных пользователя из PostgreSQL (организация, полное имя)
- Получение ролей пользователя
- Получение доступных ссылок на основе ролей
### Frontend (React + Vite)
**Функциональность:**
- Отображение информации о пользователе
- Список ролей
- Доступные ресурсы на основе ролей пользователя
- Никакой логики аутентификации (вся аутентификация на стороне DexAuthenticator)
### База данных (PostgreSQL)
**Схема:**
- `organizations` - Организации
- `users` - Пользователи
- `roles` - Роли
- `user_roles` - Связь пользователей и ролей
- `links` - Доступные ссылки
- `role_links` - Связь ролей и ссылок
- `GET /api/health` — проверка здоровья
- `GET /api/user-info` — информация о пользователе и доступных ресурсах
- Валидирует JWT (подпись, issuer, exp) и читает email/id
- Забирает пользователя, роли и ссылки из PostgreSQL
### Тестовые данные
@@ -210,19 +158,15 @@ make clean
### Backend переменные окружения
- `DB_HOST` - Хост PostgreSQL (по умолчанию: `postgres`)
- `DB_PORT` - Порт PostgreSQL (по умолчанию: `5432`)
- `DB_NAME` - Имя БД (по умолчанию: `dexdemo`)
- `DB_USER` - Пользователь БД (по умолчанию: `dexdemo`)
- `DB_PASSWORD` - Пароль БД
- `DEX_ISSUER` - URL Dex issuer (по умолчанию: `https://dex.127.0.0.1.sslip.io/`)
- `DB_HOST`, `DB_PORT`, `DB_NAME`, `DB_USER`, `DB_PASSWORD`
- `DEX_ISSUER` (по умолчанию: `https://dex.127.0.0.1.sslip.io/`)
### DexAuthenticator
Настройки в `k8s/dex-authenticator.yaml`:
- `applicationDomain` - Домен приложения
- `sendAuthorizationHeader` - Отправка заголовка Authorization с JWT
- `keepUsersLoggedInFor` - Время сессии (24h)
- `applicationDomain` — домен приложения
- `sendAuthorizationHeader` отправка заголовка Authorization с JWT
- `keepUsersLoggedInFor` — время сессии
## Разработка
@@ -276,23 +220,6 @@ Frontend будет доступен на `http://localhost:5173` с прокс
## Дополнительная настройка
### Изменение тестовых пользователей
Отредактируйте `db/init.sql` или `k8s/postgres.yaml` (ConfigMap `postgres-init`), затем:
```bash
kubectl delete pod -n navigator-demo -l app=postgres
```
### Использование собственного домена
Измените `applicationDomain` в `k8s/dex-authenticator.yaml` и `host` в `k8s/ingress.yaml`
### Production deployment
Для production окружения:
1. Используйте secrets для паролей БД
3. Включите TLS сертификаты
4. Настройте resource limits
5. Добавьте HorizontalPodAutoscaler
6. Используйте внешний PostgreSQL
- Изменение тестовых пользователей: редактируйте `db/init.sql` или `k8s/postgres.yaml`, затем `kubectl delete pod -n navigator-demo -l app=postgres`
- Собственный домен: обновите `applicationDomain` в `k8s/dex-authenticator.yaml` и `host` в `k8s/ingress.yaml`
- Production: secrets для БД, TLS, resource limits, HPA, внешний PostgreSQL

Binary file not shown.

View File

@@ -2,16 +2,26 @@ 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 =>
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}";
}

View File

@@ -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<AuthenticationSchemeOptions>
{
private readonly AppConfig _config;
public InsecureDevAuthenticationHandler(
IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger,
UrlEncoder encoder,
AppConfig config) : base(options, logger, encoder)
{
_config = config;
}
protected override Task<AuthenticateResult> 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));
}
}

View File

@@ -1,58 +0,0 @@
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);
}
}
}

View File

@@ -1,143 +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);
// // Force binding to all interfaces in Kubernetes
// Console.WriteLine($"ASPNETCORE_URLS env var: {Environment.GetEnvironmentVariable("ASPNETCORE_URLS")}");
// Console.WriteLine($"ASPNETCORE_HTTP_PORTS env var: {Environment.GetEnvironmentVariable("ASPNETCORE_HTTP_PORTS")}");
builder.Services.Configure<AppConfig>(builder.Configuration.GetSection("AppConfig"));
builder.Services.AddSingleton(sp => sp.GetRequiredService<Microsoft.Extensions.Options.IOptions<AppConfig>>().Value);
// // Clear any existing configuration and force our URL
// builder.WebHost.ConfigureKestrel(options =>
// {
// options.ListenAnyIP(8000);
// });
var config = builder.Configuration.GetSection("AppConfig").Get<AppConfig>() ?? new AppConfig();
// 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")
};
ValidateConfiguration(config);
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;
});
ConfigureJwtAuthentication(builder.Services, config);
builder.Services.AddCors(options =>
{
options.AddDefaultPolicy(policy =>
{
policy.AllowAnyOrigin()
policy.WithOrigins(config.AllowedOrigins)
.AllowAnyMethod()
.AllowAnyHeader();
.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();
// 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) =>
app.Use(async (context, next) =>
{
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)
try
{
return Results.NotFound(new { detail = "User not found in database" });
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" });
}
});
var organization = userResult.OrganizationId.HasValue && userResult.OrgName != null
? new Organization(userResult.OrganizationId.Value, userResult.OrgName)
: null;
app.MapGet("/api/health", () => Results.Ok(new HealthResponse("ok")));
// 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();
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()
));
});
// 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();
app.MapGet("/api/user-info", [Authorize] async (HttpContext context, [FromServices] AppConfig cfg) =>
{
try
{
var email = await GetUserEmail(context, cfg);
return Results.Ok(new UserInfo(userResult.Email, userResult.FullName, organization, roles, links));
await using var conn = new NpgsqlConnection(cfg.ConnectionString);
await conn.OpenAsync();
var userData = await conn.QueryAsync<UserDataResult>(@"
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 async Task<string> GetUserEmail(HttpContext context, AppConfig config, JwtValidator jwtValidator)
static void ConfigureJwtAuthentication(IServiceCollection services, AppConfig config)
{
// Dev mode
if (config.InsecureDevMode)
{
Console.WriteLine($"INSECURE_DEV_MODE: Using email {config.InsecureDevEmail}");
return config.InsecureDevEmail!;
services.AddAuthentication("InsecureDev")
.AddScheme<AuthenticationSchemeOptions, InsecureDevAuthenticationHandler>("InsecureDev", _ => { });
return;
}
// Try Authorization header
if (context.Request.Headers.TryGetValue("Authorization", out var authHeader))
{
var headerValue = authHeader.ToString();
if (headerValue.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
var token = headerValue[7..];
var payload = await jwtValidator.ValidateToken(token);
if (payload.TryGetValue("email", out var emailClaim))
options.Authority = config.Issuer;
options.RequireHttpsMetadata = false;
options.TokenValidationParameters = new TokenValidationParameters
{
return emailClaim.ToString()!;
}
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<OpenIdConnectConfiguration>(
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<string> 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);
}
}
// Try OAuth2 proxy header
if (context.Request.Headers.TryGetValue("X-Auth-Request-Email", out var emailHeader))
if (context.Request.Headers.TryGetValue(config.AuthRequestEmailHeader, out var emailHeader))
{
return emailHeader.ToString();
return Task.FromResult(emailHeader.ToString());
}
throw new UnauthorizedAccessException("No authentication information found");
}
// Internal query result model
record UserQueryResult(string Email, string FullName, int? OrganizationId, string? OrgName);
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<ClaimResponse> Claims
);

View File

@@ -13,9 +13,9 @@ dotnet run
```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/ \
-e AppConfig__DbHost=postgres \
-e AppConfig__DbPort=5440 \
-e AppConfig__Issuer=https://dex.127.0.0.1.sslip.io/ \
dex-demo-backend-dotnet:latest
```
@@ -24,11 +24,8 @@ docker run -p 8000:8000 \
- **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 класса
- **OpenIdConnect**: стандартный механизм получения JWKS
## API
@@ -37,8 +34,20 @@ docker run -p 8000:8000 \
## Переменные окружения
Все переменные идентичны Python версии:
- `DB_HOST`, `DB_PORT`, `DB_NAME`, `DB_USER`, `DB_PASSWORD`
- `DEX_ISSUER`
- `INSECURE_DEV_MODE`, `INSECURE_DEV_EMAIL`
### База данных
- `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 массив)

View File

@@ -1,10 +1,30 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
"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"
"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"]
}
}

View File

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

View File

@@ -1,10 +1,28 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
"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": "*"
"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"]
}
}

View File

@@ -9,6 +9,7 @@
<ItemGroup>
<PackageReference Include="Dapper" Version="2.1.66" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.0" />
<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" />

View File

@@ -3,7 +3,7 @@ version: '3.8'
services:
python-navigator-demo-postgres:
image: postgres:15-alpine
image: postgres:13.1-alpine
environment:
POSTGRES_DB: dexdemo
POSTGRES_USER: dexdemo
@@ -19,20 +19,37 @@ services:
timeout: 5s
retries: 5
python-navigator-demo-backend:
build: ./backend
# python-navigator-demo-backend:
# build: ./backend
# ports:
# - "8000:8000"
# environment:
# DB_HOST: postgres
# DB_PORT: 5432
# DB_NAME: dexdemo
# DB_USER: dexdemo
# DB_PASSWORD: dexdemo
# DEX_ISSUER: https://dex.127.0.0.1.sslip.io
# # Режим разработки - установите INSECURE_DEV_MODE=true для локальной разработки без OIDC
# INSECURE_DEV_MODE: "true"
# INSECURE_DEV_EMAIL: "developer@example.com"
# depends_on:
# python-navigator-demo-postgres:
# condition: service_healthy
backend-dotnet:
build: ./backend-dotnet
ports:
- "8000:8000"
- "8001:8000"
environment:
DB_HOST: postgres
DB_PORT: 5432
DB_NAME: dexdemo
DB_USER: dexdemo
DB_PASSWORD: dexdemo
DEX_ISSUER: https://dex.127.0.0.1.sslip.io
# Режим разработки - установите INSECURE_DEV_MODE=true для локальной разработки без OIDC
INSECURE_DEV_MODE: "true"
INSECURE_DEV_EMAIL: "developer@example.com"
AppConfig__DbHost: python-navigator-demo-postgres
AppConfig__DbPort: "5432"
AppConfig__DbName: dexdemo
AppConfig__DbUser: dexdemo
AppConfig__DbPassword: dexdemo
AppConfig__Issuer: https://dex.127.0.0.1.sslip.io/
AppConfig__InsecureDevMode: "true"
AppConfig__InsecureDevEmail: developer@example.com
depends_on:
python-navigator-demo-postgres:
condition: service_healthy
@@ -40,7 +57,9 @@ services:
python-navigator-demo-frontend:
build: ./frontend
ports:
- "8080:80"
- "8091:80"
depends_on:
- backend-dotnet
volumes:
python-navigator-demo-postgres_data:

16
frontend/.eslintrc.cjs Normal file
View File

@@ -0,0 +1,16 @@
module.exports = {
root: true,
env: { browser: true, es2020: true },
extends: [
'eslint:recommended',
'plugin:react/recommended',
'plugin:react/jsx-runtime',
'plugin:react-hooks/recommended',
],
ignorePatterns: ['dist', '.eslintrc.cjs'],
parserOptions: { ecmaVersion: 'latest', sourceType: 'module' },
settings: { react: { version: '18.2' } },
rules: {
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
},
}

View File

@@ -1,29 +0,0 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{js,jsx}'],
extends: [
js.configs.recommended,
reactHooks.configs['recommended-latest'],
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
parserOptions: {
ecmaVersion: 'latest',
ecmaFeatures: { jsx: true },
sourceType: 'module',
},
},
rules: {
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
},
},
])

View File

@@ -4,6 +4,22 @@ server {
root /usr/share/nginx/html;
index index.html;
# Проксирование API-запросов во внутренний сервис backend-dotnet
# Важно: без завершающего слэша, чтобы путь /api/... не обрезался до /...
location /api/ {
proxy_pass http://backend-dotnet:8000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# MIME types for JavaScript modules
location ~* \.js$ {
add_header Content-Type application/javascript;
}
# SPA fallback
location / {
try_files $uri $uri/ /index.html;
@@ -14,5 +30,4 @@ server {
gzip_vary on;
gzip_min_length 1024;
gzip_types text/plain text/css text/xml text/javascript application/javascript application/json application/xml+rss;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -10,18 +10,18 @@
"preview": "vite preview"
},
"dependencies": {
"react": "^19.1.1",
"react-dom": "^19.1.1"
"@consta/uikit": "^5.29.0",
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"devDependencies": {
"@eslint/js": "^9.36.0",
"@types/react": "^19.1.13",
"@types/react-dom": "^19.1.9",
"@vitejs/plugin-react": "^5.0.3",
"eslint": "^9.36.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20",
"globals": "^16.4.0",
"vite": "^7.1.7"
"@vitejs/plugin-react-swc": "3.7.0",
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0",
"eslint": "^8.57.0",
"eslint-plugin-react": "^7.33.0",
"eslint-plugin-react-hooks": "^4.6.0",
"globals": "^13.0.0",
"vite": "^5.1.5"
}
}

View File

@@ -1,213 +0,0 @@
:root {
--primary-color: #4f46e5;
--secondary-color: #06b6d4;
--background: #f8fafc;
--card-background: #ffffff;
--text-primary: #1e293b;
--text-secondary: #64748b;
--border-color: #e2e8f0;
--success-color: #10b981;
--error-color: #ef4444;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background: var(--background);
color: var(--text-primary);
line-height: 1.6;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
}
.header {
text-align: center;
margin-bottom: 3rem;
}
.header h1 {
font-size: 2.5rem;
color: var(--primary-color);
margin-bottom: 0.5rem;
}
.subtitle {
color: var(--text-secondary);
font-size: 1.1rem;
}
.user-card {
background: var(--card-background);
border-radius: 12px;
padding: 2rem;
box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
}
.user-card h2 {
font-size: 1.75rem;
margin-bottom: 1.5rem;
color: var(--text-primary);
}
.info-grid {
display: grid;
gap: 1rem;
margin-bottom: 2rem;
}
.info-item {
display: flex;
padding: 0.75rem;
background: var(--background);
border-radius: 8px;
}
.info-item .label {
font-weight: 600;
color: var(--text-secondary);
min-width: 150px;
}
.info-item .value {
color: var(--text-primary);
font-weight: 500;
}
.roles-section {
margin: 2rem 0;
padding-top: 2rem;
border-top: 1px solid var(--border-color);
}
.roles-section h3 {
font-size: 1.25rem;
margin-bottom: 1rem;
color: var(--text-primary);
}
.roles-list {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
}
.role-badge {
display: inline-flex;
flex-direction: column;
padding: 0.5rem 1rem;
background: linear-gradient(135deg, var(--primary-color), var(--secondary-color));
color: white;
border-radius: 8px;
font-weight: 500;
}
.role-name {
font-size: 0.95rem;
font-weight: 600;
}
.role-description {
font-size: 0.75rem;
opacity: 0.9;
margin-top: 0.25rem;
}
.links-section {
margin-top: 2rem;
padding-top: 2rem;
border-top: 1px solid var(--border-color);
}
.links-section h3 {
font-size: 1.25rem;
margin-bottom: 1rem;
color: var(--text-primary);
}
.links-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1rem;
}
.link-card {
display: block;
padding: 1.25rem;
background: var(--background);
border: 1px solid var(--border-color);
border-radius: 8px;
text-decoration: none;
color: inherit;
transition: all 0.2s ease;
}
.link-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
border-color: var(--primary-color);
}
.link-card h4 {
color: var(--primary-color);
margin-bottom: 0.5rem;
font-size: 1.1rem;
}
.link-card p {
color: var(--text-secondary);
font-size: 0.9rem;
}
.no-links {
color: var(--text-secondary);
font-style: italic;
}
.loading, .error {
text-align: center;
padding: 3rem;
font-size: 1.25rem;
}
.loading {
color: var(--text-secondary);
}
.error {
color: var(--error-color);
}
.error h2 {
margin-bottom: 1rem;
}
@media (max-width: 768px) {
.container {
padding: 1rem;
}
.header h1 {
font-size: 1.75rem;
}
.user-card {
padding: 1.5rem;
}
.links-grid {
grid-template-columns: 1fr;
}
}

View File

@@ -1,113 +1,208 @@
import { useState, useEffect } from 'react'
import './App.css'
import { useCallback, useEffect, useState } from 'react';
import { Card } from '@consta/uikit/Card';
import { Text } from '@consta/uikit/Text';
import { Badge } from '@consta/uikit/Badge';
import { Button } from '@consta/uikit/Button';
const API_URL = '/api/user-info';
const formatError = (error) => {
if (error instanceof Error && error.message) {
return error.message;
}
return 'Не удалось получить данные профиля.';
};
const EmptyState = ({ title, description }) => (
<div className="empty-state">
<Text weight="bold">{title}</Text>
{description ? (
<Text size="s" view="secondary">
{description}
</Text>
) : null}
</div>
);
function App() {
const [userInfo, setUserInfo] = useState(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
const [data, setData] = useState(null);
const [status, setStatus] = useState('loading');
const [error, setError] = useState('');
const [reloadKey, setReloadKey] = useState(0);
const reload = useCallback(() => {
setReloadKey((value) => value + 1);
}, []);
useEffect(() => {
// Получаем информацию о пользователе от бэкенда
fetch('/api/user-info')
.then(response => {
let mounted = true;
const controller = new AbortController();
const load = async () => {
setStatus('loading');
setError('');
try {
const response = await fetch(API_URL, { signal: controller.signal });
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
throw new Error(`Ошибка загрузки: ${response.status}`);
}
return response.json()
})
.then(data => {
setUserInfo(data)
setLoading(false)
})
.catch(err => {
setError(err.message)
setLoading(false)
})
}, [])
const payload = await response.json();
if (!mounted) {
return;
}
setData(payload);
setStatus('success');
} catch (err) {
if (!mounted || err?.name === 'AbortError') {
return;
}
setError(formatError(err));
setStatus('error');
}
};
if (loading) {
load();
return () => {
mounted = false;
controller.abort();
};
}, [reloadKey]);
if (status === 'loading') {
return (
<div className="container">
<div className="loading">Загрузка...</div>
<div className="app">
<Card className="section status">
<Text size="l" weight="bold">
Загрузка профиля
</Text>
<Text size="s" view="secondary">
Получаем данные о пользователе и доступных ресурсах.
</Text>
</Card>
</div>
)
);
}
if (error) {
if (status === 'error') {
return (
<div className="container">
<div className="error">
<h2>Ошибка</h2>
<p>{error}</p>
</div>
<div className="app">
<Card className="section status">
<Text size="l" weight="bold">
Не удалось загрузить данные
</Text>
<Text size="s" view="secondary">
{error}
</Text>
<div className="status-actions">
<Button label="Повторить" onClick={reload} />
</div>
</Card>
</div>
)
);
}
const roles = data?.roles ?? [];
const links = data?.available_links ?? [];
const organization = data?.organization;
return (
<div className="container">
<header className="header">
<h1>Dex Authentication Demo</h1>
<p className="subtitle">Демонстрация аутентификации через DexAuthenticator</p>
</header>
<div className="user-card">
<h2>Информация о пользователе</h2>
<div className="info-grid">
<div className="info-item">
<span className="label">Email:</span>
<span className="value">{userInfo.email || 'Не указан'}</span>
<div className="app">
<Card className="section">
<div className="profile-summary">
<div className="profile-info">
<Text size="l" weight="bold">
{data?.full_name || 'Пользователь'}
</Text>
<Text size="s" view="secondary">
{data?.email || 'Email не указан'}
</Text>
</div>
<div className="info-item">
<span className="label">Полное имя:</span>
<span className="value">{userInfo.full_name || 'Не указано'}</span>
<div className="profile-meta">
<Text size="s" view="secondary">
Организация
</Text>
<Text weight="bold">
{organization?.name || 'Не указана'}
</Text>
</div>
{userInfo.organization && (
<div className="info-item">
<span className="label">Организация:</span>
<span className="value">{userInfo.organization.name}</span>
</div>
)}
</div>
</Card>
<div className="roles-section">
<h3>Роли</h3>
<div className="roles-list">
{userInfo.roles && userInfo.roles.map(role => (
<div key={role.id} className="role-badge">
<span className="role-name">{role.name}</span>
{role.description && (
<span className="role-description">{role.description}</span>
)}
<Card className="section">
<div className="section-header">
<Text size="m" weight="bold">
Роли и доступ
</Text>
<Text size="s" view="secondary">
Список доступов пользователя внутри организации.
</Text>
</div>
{roles.length ? (
<div className="roles">
{roles.map((role) => (
<div className="role-item" key={role.id || role.name}>
<Badge label={role.name} />
<div className="role-content">
<Text size="s" view="secondary">
{role.description || 'Описание роли отсутствует.'}
</Text>
</div>
</div>
))}
</div>
</div>
) : (
<EmptyState
title="Роли не назначены"
description="Свяжитесь с администратором для выдачи доступа."
/>
)}
</Card>
<div className="links-section">
<h3>Доступные ресурсы</h3>
{userInfo.available_links && userInfo.available_links.length > 0 ? (
<div className="links-grid">
{userInfo.available_links.map(link => (
<a
key={link.id}
href={link.url}
className="link-card"
target="_blank"
rel="noopener noreferrer"
>
<h4>{link.title}</h4>
{link.description && <p>{link.description}</p>}
</a>
))}
</div>
) : (
<p className="no-links">У вас нет доступных ресурсов</p>
)}
<Card className="section">
<div className="section-header">
<Text size="m" weight="bold">
Доступные сервисы
</Text>
<Text size="s" view="secondary">
Быстрые ссылки на инструменты и ресурсы.
</Text>
</div>
</div>
{links.length ? (
<div className="links-grid">
{links.map((link) => (
<Card className="link-card" key={link.id || link.url}>
<div className="link-header">
<Text weight="bold">{link.title}</Text>
<Text size="s" view="secondary">
{link.description || 'Описание отсутствует'}
</Text>
</div>
<div className="link-action">
<Text
as="a"
href={link.url}
view="link"
size="s"
target="_blank"
rel="noreferrer"
>
Перейти
</Text>
</div>
</Card>
))}
</div>
) : (
<EmptyState
title="Пока нет доступных ссылок"
description="Ресурсы появятся после подключения сервисов."
/>
)}
</Card>
</div>
)
);
}
export default App
export default App;

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

Before

Width:  |  Height:  |  Size: 4.0 KiB

View File

@@ -1,10 +1,125 @@
:root {
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
*,
*::before,
*::after {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
background: #f5f6f7;
color: #1f1f1f;
font-family: 'Inter', 'Segoe UI', -apple-system, sans-serif;
}
#root {
min-height: 100vh;
}
.app {
max-width: 1100px;
margin: 0 auto;
padding: 32px 20px 64px;
display: flex;
flex-direction: column;
gap: 24px;
}
.section {
padding: 20px 24px;
display: flex;
flex-direction: column;
gap: 16px;
}
.section-header {
display: flex;
flex-direction: column;
gap: 6px;
}
.profile-summary {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.profile-info,
.profile-meta {
display: flex;
flex-direction: column;
gap: 6px;
}
.roles {
display: grid;
gap: 12px;
}
.role-item {
display: flex;
align-items: flex-start;
gap: 12px;
}
.links-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 16px;
}
.link-card {
padding: 16px;
display: flex;
flex-direction: column;
gap: 12px;
}
.link-header {
display: flex;
flex-direction: column;
gap: 6px;
}
.link-action {
margin-top: auto;
}
.status {
text-align: center;
align-items: center;
}
.status-actions {
margin-top: 8px;
}
.empty-state {
padding: 12px 0;
display: flex;
flex-direction: column;
gap: 6px;
}
:root {
font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI',
sans-serif;
line-height: 1.5;
font-weight: 400;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
margin: 0;
min-width: 320px;
}
code {
font-family: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Monaco,
Consolas, 'Liberation Mono', 'Courier New', monospace;
}

View File

@@ -1,10 +1,13 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import { Theme, presetGpnDefault } from '@consta/uikit/Theme'
import App from './App.jsx'
import './index.css';
createRoot(document.getElementById('root')).render(
<StrictMode>
<App />
<Theme preset={presetGpnDefault}>
<App />
</Theme>
</StrictMode>,
)

View File

@@ -1,5 +1,5 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import react from '@vitejs/plugin-react-swc'
// https://vite.dev/config/
export default defineConfig({
@@ -7,7 +7,7 @@ export default defineConfig({
server: {
proxy: {
'/api': {
target: 'http://localhost:8000',
target: process.env.VITE_API_TARGET || 'http://localhost:8001',
changeOrigin: true,
}
}

View File

@@ -42,25 +42,12 @@ spec:
env:
- name: ASPNETCORE_URLS
value: "http://0.0.0.0:8000"
# - name: ASPNETCORE_HTTP_PORTS
# value: "8000"
envFrom:
- configMapRef:
name: backend-dotnet-config
- secretRef:
name: backend-dotnet-secret
# livenessProbe:
# httpGet:
# path: /api/health
# port: 8000
# initialDelaySeconds: 10
# periodSeconds: 10
# readinessProbe:
# httpGet:
# path: /api/health
# port: 8000
# initialDelaySeconds: 5
# periodSeconds: 5
---
apiVersion: v1

Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

After

Width:  |  Height:  |  Size: 66 KiB

View File

@@ -0,0 +1,25 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.5.002.0
MinimumVisualStudioVersion = 10.0.40219.1
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "backend-dotnet", "backend-dotnet\backend-dotnet.csproj", "{B64A07E7-3E98-4140-9DC1-8D250A28AA37}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{B64A07E7-3E98-4140-9DC1-8D250A28AA37}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{B64A07E7-3E98-4140-9DC1-8D250A28AA37}.Debug|Any CPU.Build.0 = Debug|Any CPU
{B64A07E7-3E98-4140-9DC1-8D250A28AA37}.Release|Any CPU.ActiveCfg = Release|Any CPU
{B64A07E7-3E98-4140-9DC1-8D250A28AA37}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {B1D8661E-6857-4C1D-B730-52D71E82A6A9}
EndGlobalSection
EndGlobal

View File

@@ -1,85 +0,0 @@
# Диаграмма последовательности аутентификации
```mermaid
sequenceDiagram
participant User as Пользователь
participant Browser as Браузер
participant Ingress as Nginx Ingress
participant DexAuth as DexAuthenticator<br/>(OAuth2 Proxy)
participant Frontend as Frontend<br/>(React SPA)
participant Backend as Backend<br/>(FastAPI)
participant Dex as Dex<br/>(Proxy IdP)
participant BlitzIdP as Blitz IdP<br/>(Основной IdP)
participant LDAP as LDAP<br/>(Резервный IdP)
participant PostgreSQL as PostgreSQL
Note over User, PostgreSQL: Процесс аутентификации пользователя
%% Пользователь обращается к приложению
User->>Browser: 1. Открывает python-navigator-demo.127.0.0.1.sslip.io
Browser->>Ingress: 2. HTTPS запрос к приложению
Ingress->>DexAuth: 3. Проверка аутентификации через auth-url
alt Сессия не найдена или истекла
DexAuth->>DexAuth: 4. Проверка session cookie
DexAuth->>Dex: 5. Перенаправление на аутентификацию (HTTP 302)
Dex->>BlitzIdP: 6. Запрос аутентификации
alt Blitz IdP недоступен
Dex->>LDAP: 7a. Переключение на резервный IdP
LDAP-->>Dex: 7b. Ответ от LDAP
else Blitz IdP доступен
BlitzIdP-->>Dex: 7c. Ответ от Blitz IdP
end
Dex-->>DexAuth: 8. Возврат токенов (access_token, refresh_token)
DexAuth->>DexAuth: 9. Сохранение токенов в сессии
DexAuth-->>Ingress: 10. Установка заголовков (X-Auth-Request-User, X-Auth-Request-Email, Authorization)
end
Ingress->>Frontend: 11. Перенаправление на Frontend с заголовками
Frontend-->>Browser: 12. Загрузка React приложения
Browser-->>User: 13. Отображение приложения
Note over User, PostgreSQL: Взаимодействие с Backend через API
%% Пользователь взаимодействует с приложением
User->>Browser: 14. Взаимодействие с интерфейсом
Browser->>Frontend: 15. Клик/действие пользователя
Frontend->>Ingress: 16. API запрос к /api/user-info
Ingress->>DexAuth: 17. Проверка аутентификации для API
DexAuth->>DexAuth: 18. Проверка session cookie
DexAuth->>DexAuth: 19. Извлечение токенов из сессии
alt Токен истек
DexAuth->>Dex: 20. Обновление access token через refresh_token
Dex->>BlitzIdP: 21. Обновление через основной IdP
alt Blitz IdP недоступен
Dex->>LDAP: 22a. Обновление через LDAP
LDAP-->>Dex: 22b. Новый токен от LDAP
else Blitz IdP доступен
BlitzIdP-->>Dex: 22c. Новый токен от Blitz IdP
end
Dex-->>DexAuth: 23. Новый access token
DexAuth->>DexAuth: 24. Обновление сессии
end
DexAuth-->>Ingress: 25. Установка заголовков (Authorization: Bearer JWT)
Ingress->>Backend: 26. Проксирование запроса с Authorization header
Backend->>Backend: 27. Валидация JWT токена через Dex JWKS
Backend->>Backend: 28. Извлечение email из токена
Backend->>PostgreSQL: 29. Запрос данных пользователя
PostgreSQL-->>Backend: 30. Данные пользователя (роли, организация, ссылки)
Backend-->>Ingress: 31. JSON ответ с данными пользователя
Ingress-->>Frontend: 32. Ответ с данными
Frontend-->>Browser: 33. Обновление интерфейса
Browser-->>User: 34. Отображение информации о пользователе
Note over User, PostgreSQL: Преимущества архитектуры
Note right of DexAuth: ✅ SPA не хранит токены<br/>✅ Автоматическое обновление токенов<br/>✅ Безопасность на уровне инфраструктуры<br/>✅ Прозрачность для приложения<br/>✅ JWT валидация на Backend<br/>✅ Ролевая модель в PostgreSQL
```

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 MiB