Compare commits
7 Commits
4fdfb37216
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| dd0c6e6f73 | |||
| 2b1357e981 | |||
| cae6a7795b | |||
| 71a0fb48fe | |||
| 4655b4111f | |||
| 641e1fc14d | |||
| f0b8ed240f |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -35,4 +35,5 @@ yarn-error.log*
|
||||
|
||||
# Kubernetes
|
||||
*.local.yaml
|
||||
*.zip
|
||||
|
||||
|
||||
137
README.md
137
README.md
@@ -1,37 +1,25 @@
|
||||
# Dex Demo Application
|
||||
|
||||
Демонстрационное приложение для аутентификации через DexAuthenticator в Kubernetes кластере с Deckhouse.
|
||||
Демонстрационное приложение для аутентификации через DexAuthenticator в Kubernetes (Deckhouse).
|
||||
|
||||

|
||||
|
||||
## Описание
|
||||
|
||||
Простое приложение, демонстрирующее интеграцию с 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 и возвращает информацию о пользователе и доступных ресурсах
|
||||
|
||||

|
||||
|
||||
## Архитектура
|
||||
|
||||
@@ -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
|
||||
|
||||
BIN
README.pdf
BIN
README.pdf
Binary file not shown.
@@ -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 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}";
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
36
backend-dotnet/InsecureDevAuthenticationHandler.cs
Normal file
36
backend-dotnet/InsecureDevAuthenticationHandler.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -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 массив)
|
||||
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
30
backend-dotnet/appsettings.Production.json
Normal file
30
backend-dotnet/appsettings.Production.json
Normal 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"]
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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
16
frontend/.eslintrc.cjs
Normal 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_]' }],
|
||||
},
|
||||
}
|
||||
@@ -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_]' }],
|
||||
},
|
||||
},
|
||||
])
|
||||
@@ -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;
|
||||
@@ -15,4 +31,3 @@ server {
|
||||
gzip_min_length 1024;
|
||||
gzip_types text/plain text/css text/xml text/javascript application/javascript application/json application/xml+rss;
|
||||
}
|
||||
|
||||
|
||||
4125
frontend/package-lock.json
generated
4125
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 |
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
BIN
preview.png
BIN
preview.png
Binary file not shown.
|
Before Width: | Height: | Size: 73 KiB After Width: | Height: | Size: 66 KiB |
25
python-oauth2-proxy-k8s.sln
Normal file
25
python-oauth2-proxy-k8s.sln
Normal 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
|
||||
@@ -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 |
Reference in New Issue
Block a user