Initial commit: base slice (auth, roles, users, admin) scaffold

Backend: .NET 10 Clean Architecture + LiteCqrs.Net + EF Core/PostgreSQL +
Identity/JWT. Frontend: React 19 + Vite + TanStack Query/Router + Tailwind v4
with a retro CRT theme. Docker/compose deployment mirroring PnvPanel's
conventions, scoped down to the current base feature set.
This commit is contained in:
Leonid Pershin
2026-07-24 05:40:34 +03:00
commit 8a3eebc48f
156 changed files with 9335 additions and 0 deletions
+37
View File
@@ -0,0 +1,37 @@
# TeleWave — пример переменных окружения.
# Скопируй в .env и заполни значения. Ключи вида Section__Key биндятся в IOptions<T> ASP.NET Core.
# ВСЕ значения-секреты ниже обязательно заменить перед запуском (особенно *_PASSWORD, *SigningKey).
# ── PostgreSQL (контейнер db) ─────────────────────────────────────────────
POSTGRES_DB=telewave
POSTGRES_USER=telewave
POSTGRES_PASSWORD=change-me-strong-db-password
# Строка подключения приложения (host = имя сервиса БД в docker-compose)
ConnectionStrings__Default=Host=db;Port=5432;Database=telewave;Username=telewave;Password=change-me-strong-db-password
# ── JWT ───────────────────────────────────────────────────────────────────
Jwt__Issuer=TeleWave
Jwt__Audience=TeleWave
Jwt__SigningKey=change-me-min-32-chars-random-secret
Jwt__AccessTokenMinutes=15
Jwt__RefreshTokenDays=30
# ── Сид администратора (создаётся при первом старте, если не существует) ───
# Логин в систему — по username. Email в системе не используется.
AdminSeed__Username=admin
AdminSeed__Password=change-me-strong-admin-password
# ── Rate limiting ────────────────────────────────────────────────────────
# Лимит запросов/мин на auth-эндпоинты (login/register/refresh). По умолчанию 20.
# RateLimiting__AuthPermitLimit=20
# ── ASP.NET Core ──────────────────────────────────────────────────────────
ASPNETCORE_ENVIRONMENT=Production
ASPNETCORE_HTTP_PORTS=8080
# ── Доверенные прокси (X-Forwarded-For/Proto) ──────────────────────────────
# TLS терминируется вне compose внешним прокси/шлюзом. По умолчанию доверяется только loopback.
# Если прокси стоит не на loopback, перечисли его через запятую.
# ForwardedHeaders__KnownProxies=203.0.113.10
# ForwardedHeaders__KnownNetworks=172.18.0.0/16
+79
View File
@@ -0,0 +1,79 @@
# ---> VisualStudioCode
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
!.vscode/*.code-snippets
# Local History for Visual Studio Code
.history/
# Built Visual Studio Code Extensions
*.vsix
# ---> VisualStudio
# User-specific files
*.rsuser
*.suo
*.user
*.userosscache
*.sln.docstates
# Mono auto generated files
mono_crash.*
# Build results
[Dd]ebug/
[Dd]ebugPublic/
[Rr]elease/
[Rr]eleases/
x64/
x86/
[Ww][Ii][Nn]32/
[Aa][Rr][Mm]/
[Aa][Rr][Mm]64/
bld/
[Bb]in/
[Oo]bj/
[Ll]og/
[Ll]ogs/
.vs/
[Tt]est[Rr]esult*/
[Bb]uild[Ll]og.*
*.VisualState.xml
TestResult.xml
nunit-*.xml
BenchmarkDotNet.Artifacts/
project.lock.json
project.fragment.lock.json
artifacts/
*.pdb
*.log
*.tlog
# NuGet Packages
*.nupkg
*.snupkg
**/[Pp]ackages/*
!**/[Pp]ackages/build/
# Others
*.pfx
*.publishsettings
# Node
node_modules/
dist/
dist-ssr/
# Local environment files (secrets) — keep .env.example, ignore real .env
.env
.env.local
.env.*.local
+136
View File
@@ -0,0 +1,136 @@
# CLAUDE.md
Инструкции для Claude Code при работе в этом репозитории.
## Что это
**TeleWave** — сервис онлайн-каналов: пользователи смотрят сетку каналов, видео отдаётся из
хранилища на сервере. Админ управляет каналами и пользователями.
> Текущее состояние — **база**: вход/регистрация, роли, пользователи, минимальная админка,
> приветственная главная страница. Каталог каналов и сама раздача видео **не реализованы** —
> это следующий шаг. Telegram-бот сознательно не делаем.
Архитектура и код-конвенции — прямое зеркало [`D:\Github\PnvPanel`](../PnvPanel) (тот же автор,
тот же стек), но домен урезан под текущий объём фичи.
## Стек
- **Backend**: C# / .NET 10, ASP.NET Core Web API (Minimal API), Clean Architecture, CQRS через
[`LiteCqrs.Net`](https://github.com/mrleo1nid/LiteCqrs.Net) (NuGet-пакет, лёгкая
CQRS-библиотека, альтернатива MediatR с явным разделением Command/Query — см. её README).
EF Core 10 + Npgsql, ASP.NET Core Identity + JWT (access + refresh), FluentValidation, Serilog.
Маппинг DTO вручную. OpenAPI — нативный `Microsoft.AspNetCore.OpenApi` + Scalar UI.
- **Frontend**: React 19 + Vite + TS, TanStack Query/Router, shadcn-стиль поверх Radix + Tailwind
v4, Zustand (только auth+тема), react-hook-form + zod. pnpm, oxlint.
- **Инфра**: единый Docker-образ (API + статика SPA) + PostgreSQL в docker-compose.
## Архитектура — жёсткие правила
Слои и направление зависимостей: **Api → Infrastructure → Application → Domain** (внутрь).
- **Domain** — без внешних зависимостей. Сейчас единственная сущность — `Auth/RefreshToken`
(rich model: приватные сеттеры, фабричный `Issue(...)`, поведенческий `Revoke(...)`).
- **Application** — CQRS-хендлеры, DTO, валидаторы, **порты** (интерфейсы:
`IAppDbContext`, `ICurrentUser`, `IIdentityService`, `IJwtTokenService`, `IRefreshTokenService`,
`IRoleService`). Зависит только от Domain — никаких `Npgsql`/`AspNetCore.Identity`, только их
абстракции.
- **Infrastructure** — реализации портов: EF Core (`AppDbContext : IdentityDbContext<AppUser,
AppRole, Guid>`), `IdentityService`/`RoleService`/`JwtTokenService`/`RefreshTokenService`,
идемпотентный `DbInitializer` (сидинг ролей + админа).
- **Api** — Minimal API эндпоинты (`Endpoints/*Endpoints.cs`), middleware, DI composition root
(`Program.cs`).
Обязательно:
- Команды меняют состояние в транзакции (`UnitOfWorkBehavior`); запросы только читают. Диспетчер —
из `LiteCqrs.Net` (`ISender`/`ICommandHandler`/`IQueryHandler`), регистрация через
`AddLiteCqrs(...)` в `TeleWave.Application/DependencyInjection.cs`.
- Управляемые ошибки — через `Result<T>` (`Common/Models/Result.cs`), не исключениями.
- Валидация — FluentValidation через `ValidationBehavior`; хендлер не перепроверяет формат ввода.
- Всё I/O асинхронно, `CancellationToken` пробрасывается до EF/HTTP. Никаких `.Result`/`.Wait()`.
- Nullable reference types включены; `Directory.Build.props` включает
`TreatWarningsAsErrors=true` — предупреждения анализаторов не игнорировать.
## Домен: роли, пользователи, аутентификация
Полноценной доменной модели (каналы/видео) пока нет — см. заметку в начале файла. Текущие
инварианты:
- **Роли** (`AppRole : IdentityRole<Guid>`, `Infrastructure/Identity/`) — динамические,
без квот (в отличие от PnvPanel: тут нет `MaxIpLimit`/`BillingEnabled` — при появлении
доменных фич квоты/лимиты добавляются на `AppRole`/`AppUser` по мере необходимости, не заранее).
У пользователя ровно одна роль. Системные роли `admin`/`user` (`IsSystem=true`) нельзя
переименовать/удалить — проверяется в `RoleService`. Смену роли (`ChangeUserRoleCommand`)
запрещено делать так, чтобы не осталось ни одного `admin` (`RoleErrors.CannotRemoveLastAdmin`).
- **Регистрация** — открытая, без гейта активации (в отличие от PnvPanel): `RegisterCommandHandler`
создаёт пользователя с ролью `user` и сразу выдаёт токены (auto-login). Если в будущем
понадобится модерация новых пользователей — добавлять отдельным полем/флагом по аналогии с
`IsActivated` в PnvPanel, не переиспользовать `IsBlocked`.
- **Блокировка** (`AppUser.IsBlocked`) — админ блокирует/разблокирует пользователя; вход
запрещён (`LoginCommandHandler`/`RefreshCommandHandler` проверяют флаг). Админ не может
заблокировать/удалить самого себя (`UserErrors.CannotBlockSelf`/`CannotDeleteSelf`).
- **Вход по `UserName`** (email не используется, SMTP не нужен). JWT — access (короткий TTL) +
refresh (httpOnly cookie `tw_refresh_token`, `SameSite=Strict`, ротация с обнаружением повторного
использования отозванного токена — см. `RefreshTokenService.RotateAsync`).
- **Сидинг**: идемпотентный `DbInitializer` создаёт системные роли + админа из env
(`AdminSeed__Username`/`Password`) при старте. Источник примера env — `.env.example`.
## Единый контейнер
- Один образ: Api раздаёт REST (`/api`) и статику SPA из `wwwroot` (fallback на `index.html`).
Один origin, база API — относительный `/api`.
- Multi-stage Dockerfile: node (фронт) → dotnet sdk (publish + копирование в `wwwroot`) → aspnet
runtime. Никакого gosu/root-drop и Data Protection key-ring — секретов на диске пока нет
(JWT-ключ — обычная конфигурация, не шифруемый секрет at-rest); если появятся зашифрованные
данные (например API-ключи внешних сервисов) — добавлять Data Protection по образцу PnvPanel.
- docker-compose: `app` + `db` (PostgreSQL). Миграции применяются авто на старте
(`ApplyMigrationsAsync` в `Program.cs`).
- Не вводи отдельный nginx-контейнер для статики — ломает требование единого контейнера.
- **TLS — внешний**; `app` отдаёт HTTP + доверяет `X-Forwarded-*` (`ForwardedHeaders`).
## Соглашения по коду
- `<Verb><Noun>Command`/`<Get|List><Noun>Query` + `Handler`/`Validator` (валидатор — где есть что
проверить). Папки — по фичам (`Auth/Login/`, `Admin/Roles/CreateRole/`, ...).
- DTO — суффикс `Dto`; тела запросов Api-эндпоинтов, не совпадающие с командой 1:1, — `Body`
(`sealed record` внизу файла эндпоинта).
- Один публичный тип на файл = имя файла (кроме `Body` в файле эндпоинтов).
- Ошибки — статические каталоги `XErrors` (`AuthErrors`, `RoleErrors`, `UserErrors`) с
`Error.Validation/NotFound/Conflict/Unauthorized/Forbidden(...)`.
- Секреты не логировать; логи — Serilog.
- Ошибки API — единый `application/problem+json` (см. `Api/Common/ResultExtensions.cs`).
## Команды
Backend (из `backend/`):
```bash
dotnet build
dotnet test tests/TeleWave.Domain.Tests tests/TeleWave.Application.Tests
dotnet run --project src/TeleWave.Api
dotnet ef migrations add <Name> --project src/TeleWave.Infrastructure --startup-project src/TeleWave.Api
dotnet ef database update --project src/TeleWave.Infrastructure --startup-project src/TeleWave.Api
```
Frontend (из `frontend/`):
```bash
pnpm install
pnpm dev
pnpm build
pnpm lint && pnpm typecheck
```
Инфраструктура:
```bash
docker compose up -d --build # api + postgres
```
> Окружение: Windows, основная оболочка — **PowerShell**. Для POSIX-скриптов есть Bash-инструмент.
## Рабочие принципы
- Не начинай крупную реализацию (каталог каналов, раздача видео, транскодинг и т.п.) без сверки
с пользователем — это следующий большой этап после базы, архитектурные решения там ещё не приняты.
При неоднозначности — вопрос пользователю, не предположение.
- Соблюдай границы слоёв — главный инвариант проекта, как и в PnvPanel.
- Не коммить и не пуши без явной просьбы.
- Отвечай пользователю на русском.
+37
View File
@@ -0,0 +1,37 @@
# syntax=docker/dockerfile:1
# ── Stage 1: сборка фронтенда (Vite → dist) ───────────────────────────────
FROM node:22-alpine AS frontend
WORKDIR /app/frontend
RUN corepack enable
COPY frontend/package.json frontend/pnpm-lock.yaml ./
RUN corepack prepare pnpm@11.9.0 --activate && pnpm install --frozen-lockfile
COPY frontend/ ./
RUN pnpm build
# ── Stage 2: publish бэкенда, статика фронта в wwwroot ────────────────────
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS backend
WORKDIR /src
COPY backend/Directory.Build.props backend/Directory.Packages.props ./backend/
COPY backend/src/TeleWave.Domain/TeleWave.Domain.csproj ./backend/src/TeleWave.Domain/
COPY backend/src/TeleWave.Application/TeleWave.Application.csproj ./backend/src/TeleWave.Application/
COPY backend/src/TeleWave.Infrastructure/TeleWave.Infrastructure.csproj ./backend/src/TeleWave.Infrastructure/
COPY backend/src/TeleWave.Api/TeleWave.Api.csproj ./backend/src/TeleWave.Api/
RUN dotnet restore backend/src/TeleWave.Api/TeleWave.Api.csproj
COPY backend/ ./backend/
# Статика собранного фронта → wwwroot (перекрывает заглушку)
COPY --from=frontend /app/frontend/dist/ ./backend/src/TeleWave.Api/wwwroot/
RUN dotnet publish backend/src/TeleWave.Api/TeleWave.Api.csproj -c Release -o /app/publish --no-restore
# ── Stage 3: runtime ──────────────────────────────────────────────────────
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/*
ENV ASPNETCORE_ENVIRONMENT=Production \
ASPNETCORE_HTTP_PORTS=8080
EXPOSE 8080
COPY --from=backend /app/publish ./
HEALTHCHECK --interval=15s --timeout=5s --start-period=20s --retries=5 \
CMD curl -f http://localhost:8080/health || exit 1
ENTRYPOINT ["dotnet", "TeleWave.Api.dll"]
+51
View File
@@ -0,0 +1,51 @@
# TeleWave
**TeleWave** — сервис онлайн-каналов: пользователи смотрят сетку каналов, видео отдаётся из
хранилища на сервере, админ управляет каналами и пользователями.
> Текущее состояние — **база**: вход/регистрация, роли, пользователи, минимальная админка,
> приветственная главная страница. Каталог каналов и раздача видео — следующий этап, пока не
> реализованы. Приложение (фронт + бек) поставляется **единым Docker-образом**; PostgreSQL —
> отдельным контейнером в compose.
## Стек
| Слой | Технологии |
| -------- | ---------------------------------------------------------------------------------------- |
| Backend | C# / .NET 10, ASP.NET Core Web API, Clean Architecture, CQRS ([LiteCqrs.Net](https://github.com/mrleo1nid/LiteCqrs.Net)), EF Core |
| БД | PostgreSQL (Npgsql) |
| Auth | ASP.NET Core Identity + JWT (access + refresh, ротация) |
| Frontend | React 19 + Vite + TypeScript, TanStack Query/Router, shadcn/ui + Tailwind v4 (ретро-CRT тема) |
| Упаковка | Единый Docker-образ (API + статика SPA) + PostgreSQL в docker-compose |
## Быстрый старт
Единый Docker-образ + PostgreSQL:
```bash
cp .env.example .env # заполнить AdminSeed__Password, Jwt__SigningKey и т.д.
docker compose up -d --build
# → http://localhost:8085 (админ — логин/пароль из .env, AdminSeed__Username/Password)
```
Локальная разработка (без Docker для приложения — только `db`):
```bash
# backend, из backend/
dotnet build && dotnet test tests/TeleWave.Domain.Tests tests/TeleWave.Application.Tests
dotnet run --project src/TeleWave.Api
# frontend, из frontend/
pnpm install
pnpm dev # проксирует /api на localhost:8080
```
## Документация
Пример переменных окружения (сид админа, БД, JWT) — [`.env.example`](.env.example).
Инструкции для AI-ассистента (Claude Code) — в [`CLAUDE.md`](CLAUDE.md) (стек, архитектурные
правила, доменные инварианты, соглашения по коду).
## Лицензия
Не определена.
+14
View File
@@ -0,0 +1,14 @@
<Project>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
<AnalysisLevel>latest</AnalysisLevel>
<GenerateDocumentationFile>false</GenerateDocumentationFile>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<NoWarn>$(NoWarn);CA1711;CA1716;CA1848;CA1873</NoWarn>
</PropertyGroup>
</Project>
+37
View File
@@ -0,0 +1,37 @@
<Project>
<PropertyGroup>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</PropertyGroup>
<ItemGroup>
<PackageVersion Include="FluentValidation" Version="12.1.1" />
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.10" />
<PackageVersion Include="Microsoft.AspNetCore.Http.Abstractions" Version="2.3.11" />
<PackageVersion Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="10.0.10" />
<PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="10.0.10" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.10" />
<PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="10.0.10" />
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.10" />
<!-- Держим 2.x: Microsoft.AspNetCore.OpenApi 10.0.10 сам зависит от Microsoft.OpenApi >=2.0.0
(nuspec) и его Roslyn source generator (XmlCommentGenerator) скомпилирован под 2.x API —
3.x меняет IOpenApiMediaType.Example на read-only и ломает генерацию (CS0200). -->
<PackageVersion Include="Microsoft.OpenApi" Version="2.11.0" />
<PackageVersion Include="Scalar.AspNetCore" Version="2.16.16" />
<PackageVersion Include="Serilog.AspNetCore" Version="10.0.0" />
<PackageVersion Include="Microsoft.EntityFrameworkCore" Version="10.0.10" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.10">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageVersion>
<PackageVersion Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.3" />
<PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.10" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.10" />
<PackageVersion Include="System.IdentityModel.Tokens.Jwt" Version="8.21.0" />
<PackageVersion Include="LiteCqrs.Net" Version="1.0.1" />
<!-- Тестирование -->
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.8.1" />
<PackageVersion Include="xunit" Version="2.9.3" />
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.5" />
<PackageVersion Include="NSubstitute" Version="6.0.0" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.10" />
</ItemGroup>
</Project>
+12
View File
@@ -0,0 +1,12 @@
<Solution>
<Folder Name="/src/">
<Project Path="src/TeleWave.Api/TeleWave.Api.csproj" />
<Project Path="src/TeleWave.Application/TeleWave.Application.csproj" />
<Project Path="src/TeleWave.Domain/TeleWave.Domain.csproj" />
<Project Path="src/TeleWave.Infrastructure/TeleWave.Infrastructure.csproj" />
</Folder>
<Folder Name="/tests/">
<Project Path="tests/TeleWave.Domain.Tests/TeleWave.Domain.Tests.csproj" />
<Project Path="tests/TeleWave.Application.Tests/TeleWave.Application.Tests.csproj" />
</Folder>
</Solution>
@@ -0,0 +1,6 @@
namespace TeleWave.Api.Common;
public static class RateLimiting
{
public const string AuthPolicy = "auth";
}
@@ -0,0 +1,27 @@
using TeleWave.Application.Common.Models;
namespace TeleWave.Api.Common;
public static class ResultExtensions
{
public static IResult ToHttpResult(this Result result) =>
result.IsSuccess ? Results.NoContent() : ToProblem(result.Error);
public static IResult ToHttpResult<T>(this Result<T> result) =>
result.IsSuccess ? Results.Ok(result.Value) : ToProblem(result.Error);
private static IResult ToProblem(Error error)
{
var statusCode = error.Type switch
{
ErrorType.Validation => StatusCodes.Status400BadRequest,
ErrorType.Unauthorized => StatusCodes.Status401Unauthorized,
ErrorType.Forbidden => StatusCodes.Status403Forbidden,
ErrorType.NotFound => StatusCodes.Status404NotFound,
ErrorType.Conflict => StatusCodes.Status409Conflict,
_ => StatusCodes.Status422UnprocessableEntity,
};
return Results.Problem(title: error.Code, detail: error.Message, statusCode: statusCode);
}
}
@@ -0,0 +1,91 @@
using LiteCqrs;
using TeleWave.Api.Common;
using TeleWave.Application.Admin.Users.BlockUser;
using TeleWave.Application.Admin.Users.DeleteUser;
using TeleWave.Application.Admin.Users.GetUser;
using TeleWave.Application.Admin.Users.ListUsers;
using TeleWave.Application.Admin.Users.UnblockUser;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
using TeleWave.Infrastructure.Identity;
namespace TeleWave.Api.Endpoints;
public static class AdminUserEndpoints
{
public static IEndpointRouteBuilder MapAdminUserEndpoints(this IEndpointRouteBuilder app)
{
var admin = app.MapGroup("/api/admin/users")
.WithTags("Admin.Users")
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
admin.MapGet("", ListUsers).Produces<PagedList<UserSummaryDto>>();
admin.MapGet("/{id:guid}", GetUser).Produces<UserSummaryDto>();
admin
.MapPost("/{id:guid}/block", BlockUser)
.Produces(StatusCodes.Status204NoContent);
admin
.MapPost("/{id:guid}/unblock", UnblockUser)
.Produces(StatusCodes.Status204NoContent);
admin.MapDelete("/{id:guid}", DeleteUser).Produces(StatusCodes.Status204NoContent);
return app;
}
private static async Task<IResult> ListUsers(
int page,
int pageSize,
string? search,
Guid? roleId,
bool? isBlocked,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new ListUsersQuery(page <= 0 ? 1 : page, pageSize <= 0 ? 20 : pageSize, search, roleId, isBlocked),
cancellationToken
);
return Results.Ok(result);
}
private static async Task<IResult> GetUser(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new GetUserQuery(id), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> BlockUser(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new BlockUserCommand(id), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> UnblockUser(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new UnblockUserCommand(id), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> DeleteUser(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new DeleteUserCommand(id), cancellationToken);
return result.ToHttpResult();
}
}
@@ -0,0 +1,204 @@
using LiteCqrs;
using TeleWave.Api.Common;
using TeleWave.Application.Auth;
using TeleWave.Application.Auth.ChangePassword;
using TeleWave.Application.Auth.ChangeUserName;
using TeleWave.Application.Auth.DeleteMyAccount;
using TeleWave.Application.Auth.Login;
using TeleWave.Application.Auth.Logout;
using TeleWave.Application.Auth.Me;
using TeleWave.Application.Auth.Refresh;
using TeleWave.Application.Auth.Register;
namespace TeleWave.Api.Endpoints;
public static class AuthEndpoints
{
private const string RefreshCookieName = "tw_refresh_token";
public static IEndpointRouteBuilder MapAuthEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/auth")
.WithTags("Auth")
.RequireRateLimiting(RateLimiting.AuthPolicy);
group.MapPost("/register", Register).Produces<AuthResponseDto>();
group.MapPost("/login", Login).Produces<AuthResponseDto>();
group.MapPost("/refresh", Refresh).Produces<AuthResponseDto>();
group
.MapPost("/logout", Logout)
.RequireAuthorization()
.Produces(StatusCodes.Status204NoContent);
group
.MapPost("/change-password", ChangePassword)
.RequireAuthorization()
.Produces(StatusCodes.Status204NoContent);
group
.MapPost("/change-username", ChangeUserName)
.RequireAuthorization()
.Produces(StatusCodes.Status204NoContent);
group.MapGet("/me", Me).RequireAuthorization().Produces<CurrentUserDto>();
group
.MapDelete("/me", DeleteMe)
.RequireAuthorization()
.Produces(StatusCodes.Status204NoContent);
return app;
}
private static async Task<IResult> Register(
RegisterCommand command,
ISender sender,
HttpRequest request,
HttpResponse response,
CancellationToken cancellationToken
)
{
var result = await sender.Send(command, cancellationToken);
if (!result.IsSuccess)
return result.ToHttpResult();
SetRefreshCookie(
request,
response,
result.Value.RefreshToken,
result.Value.RefreshTokenExpiresAt
);
return Results.Ok(ToLoginResponse(result.Value));
}
private static async Task<IResult> Login(
LoginCommand command,
ISender sender,
HttpRequest request,
HttpResponse response,
CancellationToken cancellationToken
)
{
var result = await sender.Send(command, cancellationToken);
if (!result.IsSuccess)
return result.ToHttpResult();
SetRefreshCookie(
request,
response,
result.Value.RefreshToken,
result.Value.RefreshTokenExpiresAt
);
return Results.Ok(ToLoginResponse(result.Value));
}
private static async Task<IResult> Refresh(
HttpRequest request,
HttpResponse response,
ISender sender,
CancellationToken cancellationToken
)
{
if (
!request.Cookies.TryGetValue(RefreshCookieName, out var rawToken)
|| string.IsNullOrEmpty(rawToken)
)
return Results.Unauthorized();
var result = await sender.Send(new RefreshCommand(rawToken), cancellationToken);
if (!result.IsSuccess)
{
response.Cookies.Delete(RefreshCookieName, BuildCookieOptions(request));
return result.ToHttpResult();
}
SetRefreshCookie(
request,
response,
result.Value.RefreshToken,
result.Value.RefreshTokenExpiresAt
);
return Results.Ok(ToLoginResponse(result.Value));
}
internal static AuthResponseDto ToLoginResponse(AuthResult auth) =>
new(auth.AccessToken, auth.AccessTokenExpiresAt, auth.User);
private static async Task<IResult> Logout(
HttpRequest request,
HttpResponse response,
ISender sender,
CancellationToken cancellationToken
)
{
if (
request.Cookies.TryGetValue(RefreshCookieName, out var rawToken)
&& !string.IsNullOrEmpty(rawToken)
)
await sender.Send(new LogoutCommand(rawToken), cancellationToken);
response.Cookies.Delete(RefreshCookieName, BuildCookieOptions(request));
return Results.NoContent();
}
private static async Task<IResult> ChangePassword(
ChangePasswordCommand command,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(command, cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> ChangeUserName(
ChangeUserNameCommand command,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(command, cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> Me(ISender sender, CancellationToken cancellationToken)
{
var result = await sender.Send(new GetCurrentUserQuery(), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> DeleteMe(
HttpRequest request,
HttpResponse response,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new DeleteMyAccountCommand(), cancellationToken);
response.Cookies.Delete(RefreshCookieName, BuildCookieOptions(request));
return result.ToHttpResult();
}
private static void SetRefreshCookie(
HttpRequest request,
HttpResponse response,
string rawToken,
DateTimeOffset expiresAt
)
{
var options = BuildCookieOptions(request);
options.Expires = expiresAt;
response.Cookies.Append(RefreshCookieName, rawToken, options);
}
private static CookieOptions BuildCookieOptions(HttpRequest request) =>
new()
{
HttpOnly = true,
Secure = request.IsHttps,
SameSite = SameSiteMode.Strict,
Path = "/api/auth",
};
}
public sealed record AuthResponseDto(
string AccessToken,
DateTimeOffset ExpiresAt,
CurrentUserDto User
);
@@ -0,0 +1,86 @@
using LiteCqrs;
using TeleWave.Api.Common;
using TeleWave.Application.Admin.Roles.ChangeUserRole;
using TeleWave.Application.Admin.Roles.CreateRole;
using TeleWave.Application.Admin.Roles.DeleteRole;
using TeleWave.Application.Admin.Roles.ListRoles;
using TeleWave.Application.Admin.Roles.UpdateRole;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Infrastructure.Identity;
namespace TeleWave.Api.Endpoints;
public static class RoleEndpoints
{
public static IEndpointRouteBuilder MapRoleEndpoints(this IEndpointRouteBuilder app)
{
var admin = app.MapGroup("/api/admin")
.WithTags("Admin.Roles")
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
admin.MapGet("/roles", ListRoles).Produces<IReadOnlyList<RoleDto>>();
admin.MapPost("/roles", CreateRole).Produces<RoleDto>();
admin.MapPut("/roles/{id:guid}", UpdateRole).Produces<RoleDto>();
admin.MapDelete("/roles/{id:guid}", DeleteRole).Produces(StatusCodes.Status204NoContent);
admin
.MapPatch("/users/{id:guid}/role", ChangeUserRole)
.Produces(StatusCodes.Status204NoContent);
return app;
}
private static async Task<IResult> ListRoles(ISender sender, CancellationToken cancellationToken)
{
var roles = await sender.Send(new ListRolesQuery(), cancellationToken);
return Results.Ok(roles);
}
private static async Task<IResult> CreateRole(
CreateRoleCommand command,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(command, cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> UpdateRole(
Guid id,
UpdateRoleBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new UpdateRoleCommand(id, body.Name), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> DeleteRole(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new DeleteRoleCommand(id), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> ChangeUserRole(
Guid id,
ChangeUserRoleBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new ChangeUserRoleCommand(id, body.RoleId),
cancellationToken
);
return result.ToHttpResult();
}
}
public sealed record UpdateRoleBody(string Name);
public sealed record ChangeUserRoleBody(Guid RoleId);
+117
View File
@@ -0,0 +1,117 @@
using System.Net;
using System.Text.Json.Serialization;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.AspNetCore.RateLimiting;
using TeleWave.Api.Common;
using TeleWave.Api.Endpoints;
using TeleWave.Application;
using TeleWave.Infrastructure;
using TeleWave.Infrastructure.Identity;
using TeleWave.Infrastructure.Persistence;
using Scalar.AspNetCore;
using Serilog;
var builder = WebApplication.CreateBuilder(args);
// Структурное логирование (Serilog), конфигурация из appsettings/env.
builder.Services.AddSerilog(
(services, configuration) =>
configuration
.ReadFrom.Configuration(builder.Configuration)
.ReadFrom.Services(services)
.Enrich.FromLogContext()
);
// За внешним прокси доверяем X-Forwarded-* (TLS терминируется вне контейнера), но ТОЛЬКО от явно
// перечисленных адресов/сетей прокси — иначе клиент может подделать свой IP/схему напрямую, минуя
// прокси. По умолчанию (без конфигурации) остаётся дефолт ASP.NET Core — доверие только loopback;
// для прод-топологии прокси задаётся через ForwardedHeaders__KnownProxies/KnownNetworks (см. .env.example).
builder.Services.Configure<ForwardedHeadersOptions>(options =>
{
options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
foreach (
var proxy in builder
.Configuration.GetSection("ForwardedHeaders:KnownProxies")
.Get<string[]>()
?? []
)
options.KnownProxies.Add(IPAddress.Parse(proxy));
foreach (
var network in builder
.Configuration.GetSection("ForwardedHeaders:KnownNetworks")
.Get<string[]>()
?? []
)
{
var parts = network.Split('/');
options.KnownIPNetworks.Add(
new System.Net.IPNetwork(IPAddress.Parse(parts[0]), int.Parse(parts[1]))
);
}
});
builder.Services.AddHttpContextAccessor();
builder.Services.AddApplication();
builder.Services.AddInfrastructure(builder.Configuration);
builder.Services.AddRateLimiter(options =>
{
options.AddFixedWindowLimiter(
RateLimiting.AuthPolicy,
limiterOptions =>
{
limiterOptions.PermitLimit = builder.Configuration.GetValue(
"RateLimiting:AuthPermitLimit",
20
);
limiterOptions.Window = TimeSpan.FromMinutes(1);
limiterOptions.QueueLimit = 0;
}
);
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
});
// Энумы сериализуются строками, не числами — самодокументируемый JSON.
builder.Services.ConfigureHttpJsonOptions(options =>
options.SerializerOptions.Converters.Add(new JsonStringEnumConverter())
);
builder.Services.AddProblemDetails();
builder.Services.AddOpenApi();
builder.Services.AddHealthChecks().AddDbContextCheck<AppDbContext>();
var app = builder.Build();
// Авто-применение миграций и идемпотентный сидинг (роли + админ из env) на старте.
await app.Services.ApplyMigrationsAsync();
await app.Services.SeedDataAsync();
app.UseForwardedHeaders();
app.UseSerilogRequestLogging();
app.UseExceptionHandler();
app.UseRateLimiter();
app.UseAuthentication();
app.UseAuthorization();
app.MapOpenApi();
app.MapScalarApiReference();
app.MapHealthChecks("/health");
app.MapAuthEndpoints();
app.MapRoleEndpoints();
app.MapAdminUserEndpoints();
// Раздача статики SPA из wwwroot + fallback на index.html для клиентских маршрутов.
app.UseDefaultFiles();
app.UseStaticFiles();
app.MapFallbackToFile("index.html");
app.Run();
/// <summary>Делает неявный класс Program доступным для WebApplicationFactory&lt;Program&gt; в интеграционных тестах.</summary>
public partial class Program;
@@ -0,0 +1,14 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "http://localhost:8080",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}
@@ -0,0 +1,24 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<ItemGroup>
<ProjectReference Include="..\TeleWave.Infrastructure\TeleWave.Infrastructure.csproj" />
<ProjectReference Include="..\TeleWave.Application\TeleWave.Application.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" />
<PackageReference Include="Microsoft.OpenApi" />
<PackageReference Include="Scalar.AspNetCore" />
<PackageReference Include="Serilog.AspNetCore" />
</ItemGroup>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
</Project>
@@ -0,0 +1,12 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AdminSeed": {
"Username": "admin",
"Password": "Passw0rd!Dev"
}
}
+29
View File
@@ -0,0 +1,29 @@
{
"ConnectionStrings": {
"Default": "Host=localhost;Port=5432;Database=telewave;Username=telewave;Password=telewave"
},
"Jwt": {
"Issuer": "TeleWave",
"Audience": "TeleWave",
"SigningKey": "change-me-min-32-chars-random-secret",
"AccessTokenMinutes": 15,
"RefreshTokenDays": 30
},
"AdminSeed": {
"Username": "",
"Password": ""
},
"Serilog": {
"Using": [ "Serilog.Sinks.Console" ],
"MinimumLevel": {
"Default": "Information",
"Override": {
"Microsoft.AspNetCore": "Warning",
"Microsoft.EntityFrameworkCore": "Warning"
}
},
"WriteTo": [ { "Name": "Console" } ],
"Enrich": [ "FromLogContext" ]
},
"AllowedHosts": "*"
}
@@ -0,0 +1,7 @@
<!doctype html>
<html>
<head><meta charset="utf-8" /><title>TeleWave</title></head>
<body>
<p>Frontend build not present yet — run the Vite build to populate wwwroot.</p>
</body>
</html>
@@ -0,0 +1,6 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Admin.Roles.ChangeUserRole;
public sealed record ChangeUserRoleCommand(Guid UserId, Guid RoleId) : ICommand<Result>;
@@ -0,0 +1,12 @@
using LiteCqrs;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Admin.Roles.ChangeUserRole;
public sealed class ChangeUserRoleCommandHandler(IRoleService roleService)
: ICommandHandler<ChangeUserRoleCommand, Result>
{
public Task<Result> Handle(ChangeUserRoleCommand command, CancellationToken cancellationToken) =>
roleService.ChangeUserRoleAsync(command.UserId, command.RoleId, cancellationToken);
}
@@ -0,0 +1,7 @@
using LiteCqrs;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Admin.Roles.CreateRole;
public sealed record CreateRoleCommand(string Name) : ICommand<Result<RoleDto>>;
@@ -0,0 +1,14 @@
using LiteCqrs;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Admin.Roles.CreateRole;
public sealed class CreateRoleCommandHandler(IRoleService roleService)
: ICommandHandler<CreateRoleCommand, Result<RoleDto>>
{
public Task<Result<RoleDto>> Handle(
CreateRoleCommand command,
CancellationToken cancellationToken
) => roleService.CreateRoleAsync(command.Name, cancellationToken);
}
@@ -0,0 +1,11 @@
using FluentValidation;
namespace TeleWave.Application.Admin.Roles.CreateRole;
public sealed class CreateRoleCommandValidator : AbstractValidator<CreateRoleCommand>
{
public CreateRoleCommandValidator()
{
RuleFor(x => x.Name).NotEmpty().MaximumLength(64);
}
}
@@ -0,0 +1,6 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Admin.Roles.DeleteRole;
public sealed record DeleteRoleCommand(Guid Id) : ICommand<Result>;
@@ -0,0 +1,12 @@
using LiteCqrs;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Admin.Roles.DeleteRole;
public sealed class DeleteRoleCommandHandler(IRoleService roleService)
: ICommandHandler<DeleteRoleCommand, Result>
{
public Task<Result> Handle(DeleteRoleCommand command, CancellationToken cancellationToken) =>
roleService.DeleteRoleAsync(command.Id, cancellationToken);
}
@@ -0,0 +1,6 @@
using LiteCqrs;
using TeleWave.Application.Common.Interfaces;
namespace TeleWave.Application.Admin.Roles.ListRoles;
public sealed record ListRolesQuery : IQuery<IReadOnlyList<RoleDto>>;
@@ -0,0 +1,13 @@
using LiteCqrs;
using TeleWave.Application.Common.Interfaces;
namespace TeleWave.Application.Admin.Roles.ListRoles;
public sealed class ListRolesQueryHandler(IRoleService roleService)
: IQueryHandler<ListRolesQuery, IReadOnlyList<RoleDto>>
{
public Task<IReadOnlyList<RoleDto>> Handle(
ListRolesQuery query,
CancellationToken cancellationToken
) => roleService.ListRolesAsync(cancellationToken);
}
@@ -0,0 +1,28 @@
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Admin.Roles;
public static class RoleErrors
{
public static readonly Error NotFound = Error.NotFound("Roles.NotFound", "Роль не найдена.");
public static readonly Error DuplicateName = Error.Conflict(
"Roles.DuplicateName",
"Роль с таким именем уже существует."
);
public static readonly Error CannotModifySystemRole = Error.Forbidden(
"Roles.CannotModifySystemRole",
"Системную роль нельзя переименовать или удалить."
);
public static readonly Error RoleInUse = Error.Conflict(
"Roles.RoleInUse",
"Роль назначена пользователям — сначала смените им роль."
);
public static readonly Error CannotRemoveLastAdmin = Error.Conflict(
"Roles.CannotRemoveLastAdmin",
"Нельзя снять роль admin с последнего администратора."
);
}
@@ -0,0 +1,7 @@
using LiteCqrs;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Admin.Roles.UpdateRole;
public sealed record UpdateRoleCommand(Guid Id, string Name) : ICommand<Result<RoleDto>>;
@@ -0,0 +1,14 @@
using LiteCqrs;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Admin.Roles.UpdateRole;
public sealed class UpdateRoleCommandHandler(IRoleService roleService)
: ICommandHandler<UpdateRoleCommand, Result<RoleDto>>
{
public Task<Result<RoleDto>> Handle(
UpdateRoleCommand command,
CancellationToken cancellationToken
) => roleService.UpdateRoleAsync(command.Id, command.Name, cancellationToken);
}
@@ -0,0 +1,11 @@
using FluentValidation;
namespace TeleWave.Application.Admin.Roles.UpdateRole;
public sealed class UpdateRoleCommandValidator : AbstractValidator<UpdateRoleCommand>
{
public UpdateRoleCommandValidator()
{
RuleFor(x => x.Name).NotEmpty().MaximumLength(64);
}
}
@@ -0,0 +1,6 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Admin.Users.BlockUser;
public sealed record BlockUserCommand(Guid UserId) : ICommand<Result>;
@@ -0,0 +1,19 @@
using LiteCqrs;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Admin.Users.BlockUser;
public sealed class BlockUserCommandHandler(
IIdentityService identityService,
ICurrentUser currentUser
) : ICommandHandler<BlockUserCommand, Result>
{
public Task<Result> Handle(BlockUserCommand command, CancellationToken cancellationToken)
{
if (currentUser.UserId == command.UserId)
return Task.FromResult(Result.Failure(UserErrors.CannotBlockSelf));
return identityService.BlockUserAsync(command.UserId, cancellationToken);
}
}
@@ -0,0 +1,6 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Admin.Users.DeleteUser;
public sealed record DeleteUserCommand(Guid UserId) : ICommand<Result>;
@@ -0,0 +1,19 @@
using LiteCqrs;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Admin.Users.DeleteUser;
public sealed class DeleteUserCommandHandler(
IIdentityService identityService,
ICurrentUser currentUser
) : ICommandHandler<DeleteUserCommand, Result>
{
public Task<Result> Handle(DeleteUserCommand command, CancellationToken cancellationToken)
{
if (currentUser.UserId == command.UserId)
return Task.FromResult(Result.Failure(UserErrors.CannotDeleteSelf));
return identityService.DeleteUserAsync(command.UserId, cancellationToken);
}
}
@@ -0,0 +1,7 @@
using LiteCqrs;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Admin.Users.GetUser;
public sealed record GetUserQuery(Guid Id) : IQuery<Result<UserSummaryDto>>;
@@ -0,0 +1,20 @@
using LiteCqrs;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Admin.Users.GetUser;
public sealed class GetUserQueryHandler(IIdentityService identityService)
: IQueryHandler<GetUserQuery, Result<UserSummaryDto>>
{
public async Task<Result<UserSummaryDto>> Handle(
GetUserQuery query,
CancellationToken cancellationToken
)
{
var user = await identityService.GetUserAsync(query.Id, cancellationToken);
return user is null
? Result.Failure<UserSummaryDto>(UserErrors.NotFound)
: Result.Success(user);
}
}
@@ -0,0 +1,13 @@
using LiteCqrs;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Admin.Users.ListUsers;
public sealed record ListUsersQuery(
int Page,
int PageSize,
string? Search,
Guid? RoleId,
bool? IsBlocked
) : IQuery<PagedList<UserSummaryDto>>;
@@ -0,0 +1,22 @@
using LiteCqrs;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Admin.Users.ListUsers;
public sealed class ListUsersQueryHandler(IIdentityService identityService)
: IQueryHandler<ListUsersQuery, PagedList<UserSummaryDto>>
{
public Task<PagedList<UserSummaryDto>> Handle(
ListUsersQuery query,
CancellationToken cancellationToken
) =>
identityService.ListUsersAsync(
query.Page,
query.PageSize,
query.Search,
query.RoleId,
query.IsBlocked,
cancellationToken
);
}
@@ -0,0 +1,6 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Admin.Users.UnblockUser;
public sealed record UnblockUserCommand(Guid UserId) : ICommand<Result>;
@@ -0,0 +1,12 @@
using LiteCqrs;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Admin.Users.UnblockUser;
public sealed class UnblockUserCommandHandler(IIdentityService identityService)
: ICommandHandler<UnblockUserCommand, Result>
{
public Task<Result> Handle(UnblockUserCommand command, CancellationToken cancellationToken) =>
identityService.UnblockUserAsync(command.UserId, cancellationToken);
}
@@ -0,0 +1,18 @@
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Admin.Users;
public static class UserErrors
{
public static readonly Error NotFound = Error.NotFound("Users.NotFound", "Пользователь не найден.");
public static readonly Error CannotDeleteSelf = Error.Forbidden(
"Users.CannotDeleteSelf",
"Нельзя удалить собственный аккаунт через админку."
);
public static readonly Error CannotBlockSelf = Error.Forbidden(
"Users.CannotBlockSelf",
"Нельзя заблокировать собственный аккаунт."
);
}
@@ -0,0 +1,29 @@
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Auth;
public static class AuthErrors
{
public static readonly Error InvalidCredentials =
Error.Unauthorized("Auth.InvalidCredentials", "Неверное имя пользователя или пароль.");
public static readonly Error Unauthorized = Error.Unauthorized(
"Auth.Unauthorized",
"Требуется вход в систему."
);
public static readonly Error InvalidRefreshToken = Error.Unauthorized(
"Auth.InvalidRefreshToken",
"Сессия истекла, войдите заново."
);
public static readonly Error Blocked = Error.Forbidden(
"Auth.Blocked",
"Аккаунт заблокирован администратором."
);
public static readonly Error UserNameTaken = Error.Conflict(
"Auth.UserNameTaken",
"Это имя пользователя уже занято."
);
}
@@ -0,0 +1,11 @@
namespace TeleWave.Application.Auth;
public sealed record CurrentUserDto(Guid Id, string UserName, string Role);
public sealed record AuthResult(
string AccessToken,
DateTimeOffset AccessTokenExpiresAt,
string RefreshToken,
DateTimeOffset RefreshTokenExpiresAt,
CurrentUserDto User
);
@@ -0,0 +1,6 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Auth.ChangePassword;
public sealed record ChangePasswordCommand(string CurrentPassword, string NewPassword) : ICommand<Result>;
@@ -0,0 +1,27 @@
using LiteCqrs;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Auth.ChangePassword;
public sealed class ChangePasswordCommandHandler(
IIdentityService identityService,
ICurrentUser currentUser
) : ICommandHandler<ChangePasswordCommand, Result>
{
public async Task<Result> Handle(
ChangePasswordCommand command,
CancellationToken cancellationToken
)
{
if (currentUser.UserId is not { } userId)
return Result.Failure(AuthErrors.Unauthorized);
return await identityService.ChangePasswordAsync(
userId,
command.CurrentPassword,
command.NewPassword,
cancellationToken
);
}
}
@@ -0,0 +1,12 @@
using FluentValidation;
namespace TeleWave.Application.Auth.ChangePassword;
public sealed class ChangePasswordCommandValidator : AbstractValidator<ChangePasswordCommand>
{
public ChangePasswordCommandValidator()
{
RuleFor(x => x.CurrentPassword).NotEmpty();
RuleFor(x => x.NewPassword).NotEmpty().MinimumLength(8);
}
}
@@ -0,0 +1,6 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Auth.ChangeUserName;
public sealed record ChangeUserNameCommand(string NewUserName) : ICommand<Result>;
@@ -0,0 +1,26 @@
using LiteCqrs;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Auth.ChangeUserName;
public sealed class ChangeUserNameCommandHandler(
IIdentityService identityService,
ICurrentUser currentUser
) : ICommandHandler<ChangeUserNameCommand, Result>
{
public async Task<Result> Handle(
ChangeUserNameCommand command,
CancellationToken cancellationToken
)
{
if (currentUser.UserId is not { } userId)
return Result.Failure(AuthErrors.Unauthorized);
return await identityService.ChangeUserNameAsync(
userId,
command.NewUserName,
cancellationToken
);
}
}
@@ -0,0 +1,11 @@
using FluentValidation;
namespace TeleWave.Application.Auth.ChangeUserName;
public sealed class ChangeUserNameCommandValidator : AbstractValidator<ChangeUserNameCommand>
{
public ChangeUserNameCommandValidator()
{
RuleFor(x => x.NewUserName).NotEmpty().MinimumLength(3).MaximumLength(64);
}
}
@@ -0,0 +1,6 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Auth.DeleteMyAccount;
public sealed record DeleteMyAccountCommand : ICommand<Result>;
@@ -0,0 +1,22 @@
using LiteCqrs;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Auth.DeleteMyAccount;
public sealed class DeleteMyAccountCommandHandler(
IIdentityService identityService,
ICurrentUser currentUser
) : ICommandHandler<DeleteMyAccountCommand, Result>
{
public async Task<Result> Handle(
DeleteMyAccountCommand command,
CancellationToken cancellationToken
)
{
if (currentUser.UserId is not { } userId)
return Result.Failure(AuthErrors.Unauthorized);
return await identityService.DeleteUserAsync(userId, cancellationToken);
}
}
@@ -0,0 +1,6 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Auth.Login;
public sealed record LoginCommand(string UserName, string Password) : ICommand<Result<AuthResult>>;
@@ -0,0 +1,50 @@
using LiteCqrs;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Auth.Login;
public sealed class LoginCommandHandler(
IIdentityService identityService,
IJwtTokenService jwtTokenService,
IRefreshTokenService refreshTokenService
) : ICommandHandler<LoginCommand, Result<AuthResult>>
{
public async Task<Result<AuthResult>> Handle(
LoginCommand command,
CancellationToken cancellationToken
)
{
var credentialsResult = await identityService.ValidateCredentialsAsync(
command.UserName,
command.Password,
cancellationToken
);
if (!credentialsResult.IsSuccess)
return Result.Failure<AuthResult>(credentialsResult.Error);
var user = credentialsResult.Value;
var profile = await identityService.GetProfileAsync(user.Id, cancellationToken);
if (profile is null)
return Result.Failure<AuthResult>(AuthErrors.InvalidCredentials);
if (profile.IsBlocked)
return Result.Failure<AuthResult>(AuthErrors.Blocked);
var (accessToken, accessExpiresAt) = jwtTokenService.GenerateAccessToken(
new AuthenticatedUser(profile.Id, profile.UserName, profile.Role)
);
var refreshToken = await refreshTokenService.IssueAsync(profile.Id, cancellationToken);
var dto = new CurrentUserDto(profile.Id, profile.UserName, profile.Role);
return Result.Success(
new AuthResult(
accessToken,
accessExpiresAt,
refreshToken.RawToken,
refreshToken.ExpiresAt,
dto
)
);
}
}
@@ -0,0 +1,12 @@
using FluentValidation;
namespace TeleWave.Application.Auth.Login;
public sealed class LoginCommandValidator : AbstractValidator<LoginCommand>
{
public LoginCommandValidator()
{
RuleFor(x => x.UserName).NotEmpty();
RuleFor(x => x.Password).NotEmpty();
}
}
@@ -0,0 +1,6 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Auth.Logout;
public sealed record LogoutCommand(string RawToken) : ICommand<Result>;
@@ -0,0 +1,15 @@
using LiteCqrs;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Auth.Logout;
public sealed class LogoutCommandHandler(IRefreshTokenService refreshTokenService)
: ICommandHandler<LogoutCommand, Result>
{
public async Task<Result> Handle(LogoutCommand command, CancellationToken cancellationToken)
{
await refreshTokenService.RevokeAsync(command.RawToken, cancellationToken);
return Result.Success();
}
}
@@ -0,0 +1,6 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Auth.Me;
public sealed record GetCurrentUserQuery : IQuery<Result<CurrentUserDto>>;
@@ -0,0 +1,26 @@
using LiteCqrs;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Auth.Me;
public sealed class GetCurrentUserQueryHandler(
IIdentityService identityService,
ICurrentUser currentUser
) : IQueryHandler<GetCurrentUserQuery, Result<CurrentUserDto>>
{
public async Task<Result<CurrentUserDto>> Handle(
GetCurrentUserQuery query,
CancellationToken cancellationToken
)
{
if (currentUser.UserId is not { } userId)
return Result.Failure<CurrentUserDto>(AuthErrors.Unauthorized);
var profile = await identityService.GetProfileAsync(userId, cancellationToken);
if (profile is null)
return Result.Failure<CurrentUserDto>(AuthErrors.Unauthorized);
return Result.Success(new CurrentUserDto(profile.Id, profile.UserName, profile.Role));
}
}
@@ -0,0 +1,6 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Auth.Refresh;
public sealed record RefreshCommand(string RawToken) : ICommand<Result<AuthResult>>;
@@ -0,0 +1,44 @@
using LiteCqrs;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Auth.Refresh;
public sealed class RefreshCommandHandler(
IRefreshTokenService refreshTokenService,
IIdentityService identityService,
IJwtTokenService jwtTokenService
) : ICommandHandler<RefreshCommand, Result<AuthResult>>
{
public async Task<Result<AuthResult>> Handle(
RefreshCommand command,
CancellationToken cancellationToken
)
{
var rotated = await refreshTokenService.RotateAsync(command.RawToken, cancellationToken);
if (!rotated.IsSuccess)
return Result.Failure<AuthResult>(rotated.Error);
var profile = await identityService.GetProfileAsync(
rotated.Value.UserId,
cancellationToken
);
if (profile is null || profile.IsBlocked)
return Result.Failure<AuthResult>(AuthErrors.Unauthorized);
var (accessToken, accessExpiresAt) = jwtTokenService.GenerateAccessToken(
new AuthenticatedUser(profile.Id, profile.UserName, profile.Role)
);
var dto = new CurrentUserDto(profile.Id, profile.UserName, profile.Role);
return Result.Success(
new AuthResult(
accessToken,
accessExpiresAt,
rotated.Value.RawToken,
rotated.Value.ExpiresAt,
dto
)
);
}
}
@@ -0,0 +1,6 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Auth.Register;
public sealed record RegisterCommand(string UserName, string Password) : ICommand<Result<AuthResult>>;
@@ -0,0 +1,46 @@
using LiteCqrs;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Auth.Register;
public sealed class RegisterCommandHandler(
IIdentityService identityService,
IJwtTokenService jwtTokenService,
IRefreshTokenService refreshTokenService
) : ICommandHandler<RegisterCommand, Result<AuthResult>>
{
public async Task<Result<AuthResult>> Handle(
RegisterCommand command,
CancellationToken cancellationToken
)
{
var createResult = await identityService.CreateUserAsync(
command.UserName,
command.Password,
cancellationToken
);
if (!createResult.IsSuccess)
return Result.Failure<AuthResult>(createResult.Error);
var profile = await identityService.GetProfileAsync(createResult.Value, cancellationToken);
if (profile is null)
return Result.Failure<AuthResult>(AuthErrors.Unauthorized);
var (accessToken, accessExpiresAt) = jwtTokenService.GenerateAccessToken(
new AuthenticatedUser(profile.Id, profile.UserName, profile.Role)
);
var refreshToken = await refreshTokenService.IssueAsync(profile.Id, cancellationToken);
var dto = new CurrentUserDto(profile.Id, profile.UserName, profile.Role);
return Result.Success(
new AuthResult(
accessToken,
accessExpiresAt,
refreshToken.RawToken,
refreshToken.ExpiresAt,
dto
)
);
}
}
@@ -0,0 +1,12 @@
using FluentValidation;
namespace TeleWave.Application.Auth.Register;
public sealed class RegisterCommandValidator : AbstractValidator<RegisterCommand>
{
public RegisterCommandValidator()
{
RuleFor(x => x.UserName).NotEmpty().MinimumLength(3).MaximumLength(64);
RuleFor(x => x.Password).NotEmpty().MinimumLength(8);
}
}
@@ -0,0 +1,21 @@
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Common.Behaviors;
/// <summary>Строит Result/Result&lt;T&gt; failure-ответ через reflection — общий хелпер для generic pipeline behaviors.</summary>
internal static class ResultFailureFactory
{
public static TResponse Create<TResponse>(Error error)
where TResponse : Result
{
if (typeof(TResponse) == typeof(Result))
return (TResponse)(object)Result.Failure(error);
var valueType = typeof(TResponse).GetGenericArguments()[0];
var method = typeof(Result)
.GetMethod(nameof(Result.Failure), 1, [typeof(Error)])!
.MakeGenericMethod(valueType);
return (TResponse)method.Invoke(null, [error])!;
}
}
@@ -0,0 +1,29 @@
using LiteCqrs;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Common.Behaviors;
/// <summary>
/// Коммитит изменения после успешного выполнения команды. Применяется автоматически только
/// к запросам, реализующим <see cref="ICommand{TResponse}"/> — благодаря generic-ограничению
/// DI-контейнер не сможет сконструировать это поведение для запросов (IQuery).
/// </summary>
public sealed class UnitOfWorkBehavior<TRequest, TResponse>(IAppDbContext dbContext)
: IPipelineBehavior<TRequest, TResponse>
where TRequest : ICommand<TResponse>
{
public async Task<TResponse> Handle(
TRequest request,
RequestHandlerDelegate<TResponse> next,
CancellationToken cancellationToken
)
{
var response = await next();
if (response is not Result { IsSuccess: false })
await dbContext.SaveChangesAsync(cancellationToken);
return response;
}
}
@@ -0,0 +1,38 @@
using FluentValidation;
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Common.Behaviors;
public sealed class ValidationBehavior<TRequest, TResponse>(
IEnumerable<IValidator<TRequest>> validators
) : IPipelineBehavior<TRequest, TResponse>
where TRequest : notnull
where TResponse : Result
{
public async Task<TResponse> Handle(
TRequest request,
RequestHandlerDelegate<TResponse> next,
CancellationToken cancellationToken
)
{
if (!validators.Any())
return await next();
var context = new ValidationContext<TRequest>(request);
var failures = validators
.Select(v => v.Validate(context))
.SelectMany(r => r.Errors)
.ToList();
if (failures.Count == 0)
return await next();
var error = Error.Validation(
"Validation.Failed",
string.Join("; ", failures.Select(f => f.ErrorMessage))
);
return ResultFailureFactory.Create<TResponse>(error);
}
}
@@ -0,0 +1,11 @@
using Microsoft.EntityFrameworkCore;
using TeleWave.Domain.Auth;
namespace TeleWave.Application.Common.Interfaces;
public interface IAppDbContext
{
DbSet<RefreshToken> RefreshTokens { get; }
Task<int> SaveChangesAsync(CancellationToken cancellationToken);
}
@@ -0,0 +1,8 @@
namespace TeleWave.Application.Common.Interfaces;
public interface ICurrentUser
{
Guid? UserId { get; }
string? UserName { get; }
bool IsAuthenticated { get; }
}
@@ -0,0 +1,62 @@
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Common.Interfaces;
public sealed record CurrentUserProfile(Guid Id, string UserName, Guid RoleId, string Role, bool IsBlocked);
public sealed record UserSummaryDto(
Guid Id,
string UserName,
string Role,
bool IsBlocked,
DateTimeOffset CreatedAt
);
public interface IIdentityService
{
/// <summary>Создаёт пользователя и назначает роль по умолчанию (см. Infrastructure/Identity/RoleNames).</summary>
Task<Result<Guid>> CreateUserAsync(
string userName,
string password,
CancellationToken cancellationToken
);
Task<Result<AuthenticatedUser>> ValidateCredentialsAsync(
string userName,
string password,
CancellationToken cancellationToken
);
Task<CurrentUserProfile?> GetProfileAsync(Guid userId, CancellationToken cancellationToken);
Task<Result> ChangePasswordAsync(
Guid userId,
string currentPassword,
string newPassword,
CancellationToken cancellationToken
);
Task<Result> ChangeUserNameAsync(
Guid userId,
string newUserName,
CancellationToken cancellationToken
);
/// <summary>Удаляет аккаунт (самоудаление или удаление админом).</summary>
Task<Result> DeleteUserAsync(Guid userId, CancellationToken cancellationToken);
Task<Result> BlockUserAsync(Guid userId, CancellationToken cancellationToken);
Task<Result> UnblockUserAsync(Guid userId, CancellationToken cancellationToken);
Task<PagedList<UserSummaryDto>> ListUsersAsync(
int page,
int pageSize,
string? search,
Guid? roleId,
bool? isBlocked,
CancellationToken cancellationToken
);
Task<UserSummaryDto?> GetUserAsync(Guid userId, CancellationToken cancellationToken);
}
@@ -0,0 +1,8 @@
namespace TeleWave.Application.Common.Interfaces;
public sealed record AuthenticatedUser(Guid Id, string UserName, string Role);
public interface IJwtTokenService
{
(string AccessToken, DateTimeOffset ExpiresAt) GenerateAccessToken(AuthenticatedUser user);
}
@@ -0,0 +1,19 @@
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Common.Interfaces;
public sealed record IssuedRefreshToken(string RawToken, DateTimeOffset ExpiresAt);
public sealed record RotatedRefreshToken(Guid UserId, string RawToken, DateTimeOffset ExpiresAt);
public interface IRefreshTokenService
{
Task<IssuedRefreshToken> IssueAsync(Guid userId, CancellationToken cancellationToken);
Task<Result<RotatedRefreshToken>> RotateAsync(
string rawToken,
CancellationToken cancellationToken
);
Task RevokeAsync(string rawToken, CancellationToken cancellationToken);
}
@@ -0,0 +1,22 @@
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Common.Interfaces;
public sealed record RoleDto(Guid Id, string Name, bool IsSystem);
public interface IRoleService
{
Task<Result<RoleDto>> CreateRoleAsync(string name, CancellationToken cancellationToken);
Task<Result<RoleDto>> UpdateRoleAsync(
Guid roleId,
string name,
CancellationToken cancellationToken
);
Task<Result> DeleteRoleAsync(Guid roleId, CancellationToken cancellationToken);
Task<IReadOnlyList<RoleDto>> ListRolesAsync(CancellationToken cancellationToken);
Task<Result> ChangeUserRoleAsync(Guid userId, Guid roleId, CancellationToken cancellationToken);
}
@@ -0,0 +1,34 @@
namespace TeleWave.Application.Common.Models;
public enum ErrorType
{
Failure,
Validation,
NotFound,
Conflict,
Unauthorized,
Forbidden,
}
public sealed record Error(string Code, string Message, ErrorType Type = ErrorType.Failure)
{
public static readonly Error None = new(string.Empty, string.Empty);
public static Error Validation(string code, string message) =>
new(code, message, ErrorType.Validation);
public static Error NotFound(string code, string message) =>
new(code, message, ErrorType.NotFound);
public static Error Conflict(string code, string message) =>
new(code, message, ErrorType.Conflict);
public static Error Unauthorized(string code, string message) =>
new(code, message, ErrorType.Unauthorized);
public static Error Forbidden(string code, string message) =>
new(code, message, ErrorType.Forbidden);
public static Error Failure(string code, string message) =>
new(code, message, ErrorType.Failure);
}
@@ -0,0 +1,3 @@
namespace TeleWave.Application.Common.Models;
public sealed record PagedList<T>(IReadOnlyList<T> Items, int Total, int Page, int PageSize);
@@ -0,0 +1,43 @@
namespace TeleWave.Application.Common.Models;
public class Result
{
public bool IsSuccess { get; }
public Error Error { get; }
protected Result(bool isSuccess, Error error)
{
if (isSuccess && error != Error.None)
throw new InvalidOperationException("Успешный результат не может содержать ошибку.");
if (!isSuccess && error == Error.None)
throw new InvalidOperationException("Неуспешный результат обязан содержать ошибку.");
IsSuccess = isSuccess;
Error = error;
}
public static Result Success() => new(true, Error.None);
public static Result Failure(Error error) => new(false, error);
public static Result<T> Success<T>(T value) => new(value, true, Error.None);
public static Result<T> Failure<T>(Error error) => new(default, false, error);
}
public class Result<T> : Result
{
private readonly T? _value;
internal Result(T? value, bool isSuccess, Error error)
: base(isSuccess, error) => _value = value;
public T Value =>
IsSuccess
? _value!
: throw new InvalidOperationException(
"Нельзя получить значение неуспешного результата."
);
public static implicit operator Result<T>(T value) => Success(value);
}
@@ -0,0 +1,43 @@
using System.Reflection;
using FluentValidation;
using LiteCqrs.Behaviors;
using LiteCqrs.DependencyInjection;
using Microsoft.Extensions.DependencyInjection;
using TeleWave.Application.Common.Behaviors;
namespace TeleWave.Application;
public static class DependencyInjection
{
public static IServiceCollection AddApplication(this IServiceCollection services)
{
var assembly = typeof(DependencyInjection).Assembly;
services.AddLiteCqrs(cqrs =>
{
cqrs.RegisterServicesFromAssembly(assembly);
cqrs.Lifetime = ServiceLifetime.Scoped;
// Порядок важен: Logging (внешний) -> Validation -> UnitOfWork (ближе всего к хендлеру).
cqrs.AddOpenBehavior(typeof(LoggingBehavior<,>));
cqrs.AddOpenBehavior(typeof(ValidationBehavior<,>));
cqrs.AddOpenBehavior(typeof(UnitOfWorkBehavior<,>));
});
RegisterClosedGeneric(services, assembly, typeof(IValidator<>));
return services;
}
private static void RegisterClosedGeneric(IServiceCollection services, Assembly assembly, Type openInterface)
{
var implementations = assembly.GetTypes()
.Where(t => t is { IsClass: true, IsAbstract: false })
.SelectMany(t => t.GetInterfaces()
.Where(i => i.IsGenericType && i.GetGenericTypeDefinition() == openInterface)
.Select(i => (Service: i, Implementation: t)));
foreach (var (service, implementation) in implementations)
services.AddScoped(service, implementation);
}
}
@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\TeleWave.Domain\TeleWave.Domain.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="FluentValidation" />
<PackageReference Include="LiteCqrs.Net" />
<PackageReference Include="Microsoft.EntityFrameworkCore" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
</ItemGroup>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>
@@ -0,0 +1,39 @@
namespace TeleWave.Domain.Auth;
/// <summary>
/// Refresh-токен пользователя. Хранится только SHA-256 хэш (см. IRefreshTokenService в Infrastructure) —
/// сырой токен нигде не персистится, кроме httpOnly-cookie на клиенте.
/// </summary>
public class RefreshToken
{
public Guid Id { get; private set; }
public Guid UserId { get; private set; }
public string TokenHash { get; private set; } = string.Empty;
public DateTimeOffset ExpiresAt { get; private set; }
public DateTimeOffset CreatedAt { get; private set; }
public DateTimeOffset? RevokedAt { get; private set; }
/// <summary>Хэш токена, которым этот был заменён при ротации — цепочка для обнаружения повторного
/// использования уже отозванного токена (признак компрометации).</summary>
public string? ReplacedByTokenHash { get; private set; }
private RefreshToken() { }
public static RefreshToken Issue(Guid userId, string tokenHash, DateTimeOffset expiresAt) =>
new()
{
Id = Guid.NewGuid(),
UserId = userId,
TokenHash = tokenHash,
ExpiresAt = expiresAt,
CreatedAt = DateTimeOffset.UtcNow,
};
public bool IsActive => RevokedAt is null && ExpiresAt > DateTimeOffset.UtcNow;
public void Revoke(string? replacedByTokenHash = null)
{
RevokedAt = DateTimeOffset.UtcNow;
ReplacedByTokenHash = replacedByTokenHash;
}
}
@@ -0,0 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>
@@ -0,0 +1,90 @@
using System.Text;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.IdentityModel.Tokens;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Infrastructure.Identity;
using TeleWave.Infrastructure.Persistence;
namespace TeleWave.Infrastructure;
/// <summary>
/// Регистрация сервисов инфраструктуры: EF Core (PostgreSQL), Identity/JWT-аутентификация.
/// </summary>
public static class DependencyInjection
{
public static IServiceCollection AddInfrastructure(
this IServiceCollection services,
IConfiguration configuration
)
{
services.AddDbContext<AppDbContext>(options =>
options.UseNpgsql(
configuration["ConnectionStrings:Default"]
?? throw new InvalidOperationException(
"Строка подключения 'ConnectionStrings:Default' не сконфигурирована."
)
)
);
services.AddScoped<IAppDbContext>(sp => sp.GetRequiredService<AppDbContext>());
services
.AddIdentityCore<AppUser>(options =>
{
options.User.RequireUniqueEmail = false;
options.Password.RequiredLength = 8;
options.Password.RequireDigit = true;
options.Password.RequireUppercase = true;
options.Password.RequireNonAlphanumeric = false;
options.Lockout.MaxFailedAccessAttempts = 5;
options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(5);
options.Lockout.AllowedForNewUsers = true;
})
.AddRoles<AppRole>()
.AddEntityFrameworkStores<AppDbContext>()
.AddSignInManager()
.AddDefaultTokenProviders();
services.Configure<JwtOptions>(configuration.GetSection(JwtOptions.SectionName));
services.Configure<AdminSeedOptions>(
configuration.GetSection(AdminSeedOptions.SectionName)
);
var jwtOptions =
configuration.GetSection(JwtOptions.SectionName).Get<JwtOptions>()
?? throw new InvalidOperationException("Секция конфигурации 'Jwt' не задана.");
services
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidIssuer = jwtOptions.Issuer,
ValidateAudience = true,
ValidAudience = jwtOptions.Audience,
ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(jwtOptions.SigningKey)
),
ValidateLifetime = true,
ClockSkew = TimeSpan.FromSeconds(30),
};
});
services.AddAuthorization();
services.AddScoped<IIdentityService, IdentityService>();
services.AddScoped<IJwtTokenService, JwtTokenService>();
services.AddScoped<IRefreshTokenService, RefreshTokenService>();
services.AddScoped<IRoleService, RoleService>();
services.AddScoped<ICurrentUser, CurrentUser>();
services.AddScoped<DbInitializer>();
return services;
}
}
@@ -0,0 +1,9 @@
namespace TeleWave.Infrastructure.Identity;
public sealed class AdminSeedOptions
{
public const string SectionName = "AdminSeed";
public string? Username { get; init; }
public string? Password { get; init; }
}
@@ -0,0 +1,15 @@
using Microsoft.AspNetCore.Identity;
namespace TeleWave.Infrastructure.Identity;
/// <summary>Роль пользователя. У пользователя ровно одна роль. Системные роли (admin/user) нельзя
/// переименовать или удалить (см. RoleService).</summary>
public class AppRole : IdentityRole<Guid>
{
public bool IsSystem { get; set; }
public AppRole() { }
public AppRole(string name)
: base(name) { }
}
@@ -0,0 +1,11 @@
using Microsoft.AspNetCore.Identity;
namespace TeleWave.Infrastructure.Identity;
/// <summary>Пользователь. Вход — по UserName; Email в системе не используется.</summary>
public class AppUser : IdentityUser<Guid>
{
public bool IsBlocked { get; set; }
public DateTimeOffset CreatedAt { get; set; }
}
@@ -0,0 +1,22 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Http;
using TeleWave.Application.Common.Interfaces;
namespace TeleWave.Infrastructure.Identity;
internal sealed class CurrentUser(IHttpContextAccessor httpContextAccessor) : ICurrentUser
{
private ClaimsPrincipal? Principal => httpContextAccessor.HttpContext?.User;
public Guid? UserId => ParseHttpUserId();
public string? UserName => Principal?.FindFirstValue(ClaimTypes.Name);
public bool IsAuthenticated => Principal?.Identity?.IsAuthenticated ?? false;
private Guid? ParseHttpUserId()
{
var value = Principal?.FindFirstValue(ClaimTypes.NameIdentifier);
return Guid.TryParse(value, out var id) ? id : null;
}
}
@@ -0,0 +1,69 @@
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace TeleWave.Infrastructure.Identity;
/// <summary>Идемпотентный сидинг: системные роли + учётка администратора из env.</summary>
public sealed class DbInitializer(
RoleManager<AppRole> roleManager,
UserManager<AppUser> userManager,
IOptions<AdminSeedOptions> adminSeedOptions,
ILogger<DbInitializer> logger
)
{
public async Task SeedAsync(CancellationToken cancellationToken = default)
{
await EnsureRoleAsync(RoleNames.Admin);
await EnsureRoleAsync(RoleNames.User);
await SeedAdminAsync();
}
private async Task EnsureRoleAsync(string name)
{
if (await roleManager.RoleExistsAsync(name))
return;
var role = new AppRole(name) { IsSystem = true };
var result = await roleManager.CreateAsync(role);
if (!result.Succeeded)
{
throw new InvalidOperationException(
$"Failed to create role '{name}': {string.Join(", ", result.Errors.Select(e => e.Description))}"
);
}
logger.LogInformation("Created system role {RoleName}", name);
}
private async Task SeedAdminAsync()
{
var options = adminSeedOptions.Value;
if (
string.IsNullOrWhiteSpace(options.Username)
|| string.IsNullOrWhiteSpace(options.Password)
)
{
logger.LogWarning(
"AdminSeed__Username/AdminSeed__Password not set — admin account not created"
);
return;
}
if (await userManager.FindByNameAsync(options.Username) is not null)
return;
var admin = new AppUser { UserName = options.Username, CreatedAt = DateTimeOffset.UtcNow };
var createResult = await userManager.CreateAsync(admin, options.Password);
if (!createResult.Succeeded)
{
throw new InvalidOperationException(
$"Failed to create admin account: {string.Join(", ", createResult.Errors.Select(e => e.Description))}"
);
}
await userManager.AddToRoleAsync(admin, RoleNames.Admin);
logger.LogInformation("Created admin account {Username}", options.Username);
}
}
@@ -0,0 +1,16 @@
using Microsoft.Extensions.DependencyInjection;
namespace TeleWave.Infrastructure.Identity;
public static class DbInitializerExtensions
{
public static async Task SeedDataAsync(
this IServiceProvider services,
CancellationToken cancellationToken = default
)
{
await using var scope = services.CreateAsyncScope();
var initializer = scope.ServiceProvider.GetRequiredService<DbInitializer>();
await initializer.SeedAsync(cancellationToken);
}
}
@@ -0,0 +1,225 @@
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Admin.Users;
using TeleWave.Application.Auth;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
using TeleWave.Infrastructure.Persistence;
namespace TeleWave.Infrastructure.Identity;
internal sealed class IdentityService(
UserManager<AppUser> userManager,
SignInManager<AppUser> signInManager,
RoleManager<AppRole> roleManager,
AppDbContext dbContext
) : IIdentityService
{
public async Task<Result<Guid>> CreateUserAsync(
string userName,
string password,
CancellationToken cancellationToken
)
{
if (await userManager.FindByNameAsync(userName) is not null)
return Result.Failure<Guid>(AuthErrors.UserNameTaken);
var user = new AppUser { UserName = userName, CreatedAt = DateTimeOffset.UtcNow };
var createResult = await userManager.CreateAsync(user, password);
if (!createResult.Succeeded)
{
return Result.Failure<Guid>(
Error.Validation(
"Auth.CreateFailed",
string.Join("; ", createResult.Errors.Select(e => e.Description))
)
);
}
await userManager.AddToRoleAsync(user, RoleNames.User);
return Result.Success(user.Id);
}
public async Task<Result<AuthenticatedUser>> ValidateCredentialsAsync(
string userName,
string password,
CancellationToken cancellationToken
)
{
var user = await userManager.FindByNameAsync(userName);
if (user is null)
return Result.Failure<AuthenticatedUser>(AuthErrors.InvalidCredentials);
var checkResult = await signInManager.CheckPasswordSignInAsync(
user,
password,
lockoutOnFailure: true
);
if (!checkResult.Succeeded)
return Result.Failure<AuthenticatedUser>(AuthErrors.InvalidCredentials);
var role = await GetPrimaryRoleAsync(user);
return Result.Success(new AuthenticatedUser(user.Id, user.UserName!, role));
}
public async Task<CurrentUserProfile?> GetProfileAsync(
Guid userId,
CancellationToken cancellationToken
)
{
var user = await userManager.FindByIdAsync(userId.ToString());
if (user is null)
return null;
var role = await GetPrimaryRoleAsync(user);
var roleEntity = await roleManager.FindByNameAsync(role);
return new CurrentUserProfile(user.Id, user.UserName!, roleEntity?.Id ?? Guid.Empty, role, user.IsBlocked);
}
public async Task<Result> ChangePasswordAsync(
Guid userId,
string currentPassword,
string newPassword,
CancellationToken cancellationToken
)
{
var user = await userManager.FindByIdAsync(userId.ToString());
if (user is null)
return Result.Failure(AuthErrors.Unauthorized);
var result = await userManager.ChangePasswordAsync(user, currentPassword, newPassword);
return result.Succeeded
? Result.Success()
: Result.Failure(
Error.Validation(
"Auth.ChangePasswordFailed",
string.Join("; ", result.Errors.Select(e => e.Description))
)
);
}
public async Task<Result> ChangeUserNameAsync(
Guid userId,
string newUserName,
CancellationToken cancellationToken
)
{
var user = await userManager.FindByIdAsync(userId.ToString());
if (user is null)
return Result.Failure(AuthErrors.Unauthorized);
var existing = await userManager.FindByNameAsync(newUserName);
if (existing is not null && existing.Id != userId)
return Result.Failure(AuthErrors.UserNameTaken);
var result = await userManager.SetUserNameAsync(user, newUserName);
return result.Succeeded
? Result.Success()
: Result.Failure(
Error.Validation(
"Auth.ChangeUserNameFailed",
string.Join("; ", result.Errors.Select(e => e.Description))
)
);
}
public async Task<Result> DeleteUserAsync(Guid userId, CancellationToken cancellationToken)
{
var user = await userManager.FindByIdAsync(userId.ToString());
if (user is null)
return Result.Failure(UserErrors.NotFound);
var result = await userManager.DeleteAsync(user);
return result.Succeeded
? Result.Success()
: Result.Failure(
Error.Failure(
"Users.DeleteFailed",
string.Join("; ", result.Errors.Select(e => e.Description))
)
);
}
public async Task<Result> BlockUserAsync(Guid userId, CancellationToken cancellationToken)
{
var user = await userManager.FindByIdAsync(userId.ToString());
if (user is null)
return Result.Failure(UserErrors.NotFound);
user.IsBlocked = true;
await userManager.UpdateAsync(user);
return Result.Success();
}
public async Task<Result> UnblockUserAsync(Guid userId, CancellationToken cancellationToken)
{
var user = await userManager.FindByIdAsync(userId.ToString());
if (user is null)
return Result.Failure(UserErrors.NotFound);
user.IsBlocked = false;
await userManager.UpdateAsync(user);
return Result.Success();
}
public async Task<PagedList<UserSummaryDto>> ListUsersAsync(
int page,
int pageSize,
string? search,
Guid? roleId,
bool? isBlocked,
CancellationToken cancellationToken
)
{
var query =
from user in dbContext.Users
join userRole in dbContext.UserRoles on user.Id equals userRole.UserId into userRoles
from userRole in userRoles.DefaultIfEmpty()
join role in dbContext.Roles on userRole.RoleId equals role.Id into roles
from role in roles.DefaultIfEmpty()
select new { user, RoleId = (Guid?)userRole.RoleId, RoleName = role != null ? role.Name : null };
if (!string.IsNullOrWhiteSpace(search))
query = query.Where(x => x.user.UserName!.Contains(search));
if (roleId is not null)
query = query.Where(x => x.RoleId == roleId);
if (isBlocked is not null)
query = query.Where(x => x.user.IsBlocked == isBlocked);
var total = await query.CountAsync(cancellationToken);
var items = await query
.OrderByDescending(x => x.user.CreatedAt)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.Select(x => new UserSummaryDto(
x.user.Id,
x.user.UserName!,
x.RoleName ?? RoleNames.User,
x.user.IsBlocked,
x.user.CreatedAt
))
.ToListAsync(cancellationToken);
return new PagedList<UserSummaryDto>(items, total, page, pageSize);
}
public async Task<UserSummaryDto?> GetUserAsync(Guid userId, CancellationToken cancellationToken)
{
var user = await userManager.FindByIdAsync(userId.ToString());
if (user is null)
return null;
var role = await GetPrimaryRoleAsync(user);
return new UserSummaryDto(user.Id, user.UserName!, role, user.IsBlocked, user.CreatedAt);
}
private async Task<string> GetPrimaryRoleAsync(AppUser user)
{
var roles = await userManager.GetRolesAsync(user);
return roles.FirstOrDefault() ?? RoleNames.User;
}
}
@@ -0,0 +1,12 @@
namespace TeleWave.Infrastructure.Identity;
public sealed class JwtOptions
{
public const string SectionName = "Jwt";
public string Issuer { get; init; } = string.Empty;
public string Audience { get; init; } = string.Empty;
public string SigningKey { get; init; } = string.Empty;
public int AccessTokenMinutes { get; init; } = 15;
public int RefreshTokenDays { get; init; } = 30;
}
@@ -0,0 +1,42 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
using TeleWave.Application.Common.Interfaces;
namespace TeleWave.Infrastructure.Identity;
internal sealed class JwtTokenService(IOptions<JwtOptions> options) : IJwtTokenService
{
private readonly JwtOptions _options = options.Value;
public (string AccessToken, DateTimeOffset ExpiresAt) GenerateAccessToken(
AuthenticatedUser user
)
{
var expiresAt = DateTimeOffset.UtcNow.AddMinutes(_options.AccessTokenMinutes);
Claim[] claims =
[
new Claim(JwtRegisteredClaimNames.Sub, user.Id.ToString()),
new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()),
new Claim(ClaimTypes.Name, user.UserName),
new Claim(ClaimTypes.Role, user.Role),
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
];
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_options.SigningKey));
var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
var token = new JwtSecurityToken(
issuer: _options.Issuer,
audience: _options.Audience,
claims: claims,
expires: expiresAt.UtcDateTime,
signingCredentials: credentials
);
return (new JwtSecurityTokenHandler().WriteToken(token), expiresAt);
}
}
@@ -0,0 +1,99 @@
using System.Security.Cryptography;
using System.Text;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using TeleWave.Application.Auth;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
using TeleWave.Domain.Auth;
using TeleWave.Infrastructure.Persistence;
namespace TeleWave.Infrastructure.Identity;
internal sealed class RefreshTokenService(AppDbContext dbContext, IOptions<JwtOptions> options)
: IRefreshTokenService
{
private readonly JwtOptions _options = options.Value;
public async Task<IssuedRefreshToken> IssueAsync(
Guid userId,
CancellationToken cancellationToken
)
{
var rawToken = GenerateRawToken();
var expiresAt = DateTimeOffset.UtcNow.AddDays(_options.RefreshTokenDays);
dbContext.RefreshTokens.Add(RefreshToken.Issue(userId, Hash(rawToken), expiresAt));
await dbContext.SaveChangesAsync(cancellationToken);
return new IssuedRefreshToken(rawToken, expiresAt);
}
public async Task<Result<RotatedRefreshToken>> RotateAsync(
string rawToken,
CancellationToken cancellationToken
)
{
var hash = Hash(rawToken);
var existing = await dbContext.RefreshTokens.SingleOrDefaultAsync(
t => t.TokenHash == hash,
cancellationToken
);
if (existing is null)
return Result.Failure<RotatedRefreshToken>(AuthErrors.InvalidRefreshToken);
if (!existing.IsActive)
{
if (existing.RevokedAt is not null)
{
// Повторное использование уже отозванного токена — признак компрометации: гасим все токены пользователя.
await RevokeAllForUserAsync(existing.UserId, cancellationToken);
}
return Result.Failure<RotatedRefreshToken>(AuthErrors.InvalidRefreshToken);
}
var newRawToken = GenerateRawToken();
var newExpiresAt = DateTimeOffset.UtcNow.AddDays(_options.RefreshTokenDays);
var newHash = Hash(newRawToken);
existing.Revoke(newHash);
dbContext.RefreshTokens.Add(RefreshToken.Issue(existing.UserId, newHash, newExpiresAt));
await dbContext.SaveChangesAsync(cancellationToken);
return Result.Success(new RotatedRefreshToken(existing.UserId, newRawToken, newExpiresAt));
}
public async Task RevokeAsync(string rawToken, CancellationToken cancellationToken)
{
var hash = Hash(rawToken);
var existing = await dbContext.RefreshTokens.SingleOrDefaultAsync(
t => t.TokenHash == hash,
cancellationToken
);
if (existing is null || existing.RevokedAt is not null)
return;
existing.Revoke();
await dbContext.SaveChangesAsync(cancellationToken);
}
private async Task RevokeAllForUserAsync(Guid userId, CancellationToken cancellationToken)
{
var activeTokens = await dbContext
.RefreshTokens.Where(t => t.UserId == userId && t.RevokedAt == null)
.ToListAsync(cancellationToken);
foreach (var token in activeTokens)
token.Revoke();
await dbContext.SaveChangesAsync(cancellationToken);
}
private static string GenerateRawToken() =>
Convert.ToBase64String(RandomNumberGenerator.GetBytes(64));
private static string Hash(string rawToken) =>
Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(rawToken)));
}
@@ -0,0 +1,7 @@
namespace TeleWave.Infrastructure.Identity;
public static class RoleNames
{
public const string Admin = "admin";
public const string User = "user";
}
@@ -0,0 +1,117 @@
using Microsoft.AspNetCore.Identity;
using TeleWave.Application.Admin.Roles;
using TeleWave.Application.Admin.Users;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Infrastructure.Identity;
internal sealed class RoleService(RoleManager<AppRole> roleManager, UserManager<AppUser> userManager)
: IRoleService
{
public async Task<Result<RoleDto>> CreateRoleAsync(string name, CancellationToken cancellationToken)
{
if (await roleManager.RoleExistsAsync(name))
return Result.Failure<RoleDto>(RoleErrors.DuplicateName);
var role = new AppRole(name) { IsSystem = false };
var result = await roleManager.CreateAsync(role);
if (!result.Succeeded)
{
return Result.Failure<RoleDto>(
Error.Validation(
"Roles.CreateFailed",
string.Join("; ", result.Errors.Select(e => e.Description))
)
);
}
return Result.Success(ToDto(role));
}
public async Task<Result<RoleDto>> UpdateRoleAsync(
Guid roleId,
string name,
CancellationToken cancellationToken
)
{
var role = await roleManager.FindByIdAsync(roleId.ToString());
if (role is null)
return Result.Failure<RoleDto>(RoleErrors.NotFound);
if (role.IsSystem)
return Result.Failure<RoleDto>(RoleErrors.CannotModifySystemRole);
if (
await roleManager.FindByNameAsync(name) is { } existing
&& existing.Id != roleId
)
return Result.Failure<RoleDto>(RoleErrors.DuplicateName);
role.Name = name;
await roleManager.UpdateAsync(role);
return Result.Success(ToDto(role));
}
public async Task<Result> DeleteRoleAsync(Guid roleId, CancellationToken cancellationToken)
{
var role = await roleManager.FindByIdAsync(roleId.ToString());
if (role is null)
return Result.Failure(RoleErrors.NotFound);
if (role.IsSystem)
return Result.Failure(RoleErrors.CannotModifySystemRole);
var usersInRole = await userManager.GetUsersInRoleAsync(role.Name!);
if (usersInRole.Count > 0)
return Result.Failure(RoleErrors.RoleInUse);
await roleManager.DeleteAsync(role);
return Result.Success();
}
public Task<IReadOnlyList<RoleDto>> ListRolesAsync(CancellationToken cancellationToken)
{
IReadOnlyList<RoleDto> roles = roleManager
.Roles.OrderBy(r => r.Name)
.Select(r => new RoleDto(r.Id, r.Name!, r.IsSystem))
.ToList();
return Task.FromResult(roles);
}
public async Task<Result> ChangeUserRoleAsync(
Guid userId,
Guid roleId,
CancellationToken cancellationToken
)
{
var user = await userManager.FindByIdAsync(userId.ToString());
if (user is null)
return Result.Failure(UserErrors.NotFound);
var role = await roleManager.FindByIdAsync(roleId.ToString());
if (role is null)
return Result.Failure(RoleErrors.NotFound);
var currentRoles = await userManager.GetRolesAsync(user);
var wasAdmin = currentRoles.Contains(RoleNames.Admin, StringComparer.OrdinalIgnoreCase);
var staysAdmin = role.Name!.Equals(RoleNames.Admin, StringComparison.OrdinalIgnoreCase);
if (wasAdmin && !staysAdmin)
{
var adminCount = (await userManager.GetUsersInRoleAsync(RoleNames.Admin)).Count;
if (adminCount <= 1)
return Result.Failure(RoleErrors.CannotRemoveLastAdmin);
}
if (currentRoles.Count > 0)
await userManager.RemoveFromRolesAsync(user, currentRoles);
await userManager.AddToRoleAsync(user, role.Name!);
return Result.Success();
}
private static RoleDto ToDto(AppRole role) => new(role.Id, role.Name!, role.IsSystem);
}
@@ -0,0 +1,320 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using TeleWave.Infrastructure.Persistence;
#nullable disable
namespace TeleWave.Infrastructure.Migrations
{
[DbContext(typeof(AppDbContext))]
[Migration("20260724021315_InitialCreate")]
partial class InitialCreate
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.10")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<System.Guid>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("text");
b.Property<string>("ClaimValue")
.HasColumnType("text");
b.Property<Guid>("RoleId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("RoleId");
b.ToTable("AspNetRoleClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("text");
b.Property<string>("ClaimValue")
.HasColumnType("text");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("AspNetUserClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b =>
{
b.Property<string>("LoginProvider")
.HasColumnType("text");
b.Property<string>("ProviderKey")
.HasColumnType("text");
b.Property<string>("ProviderDisplayName")
.HasColumnType("text");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("LoginProvider", "ProviderKey");
b.HasIndex("UserId");
b.ToTable("AspNetUserLogins", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<System.Guid>", b =>
{
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.Property<Guid>("RoleId")
.HasColumnType("uuid");
b.HasKey("UserId", "RoleId");
b.HasIndex("RoleId");
b.ToTable("AspNetUserRoles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<System.Guid>", b =>
{
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.Property<string>("LoginProvider")
.HasColumnType("text");
b.Property<string>("Name")
.HasColumnType("text");
b.Property<string>("Value")
.HasColumnType("text");
b.HasKey("UserId", "LoginProvider", "Name");
b.ToTable("AspNetUserTokens", (string)null);
});
modelBuilder.Entity("TeleWave.Domain.Auth.RefreshToken", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset>("ExpiresAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("ReplacedByTokenHash")
.HasColumnType("text");
b.Property<DateTimeOffset?>("RevokedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("TokenHash")
.IsRequired()
.HasColumnType("text");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("TokenHash")
.IsUnique();
b.HasIndex("UserId");
b.ToTable("RefreshTokens");
});
modelBuilder.Entity("TeleWave.Infrastructure.Identity.AppRole", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("text");
b.Property<bool>("IsSystem")
.HasColumnType("boolean");
b.Property<string>("Name")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("NormalizedName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.HasKey("Id");
b.HasIndex("NormalizedName")
.IsUnique()
.HasDatabaseName("RoleNameIndex");
b.ToTable("AspNetRoles", (string)null);
});
modelBuilder.Entity("TeleWave.Infrastructure.Identity.AppUser", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<int>("AccessFailedCount")
.HasColumnType("integer");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("text");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<bool>("EmailConfirmed")
.HasColumnType("boolean");
b.Property<bool>("IsBlocked")
.HasColumnType("boolean");
b.Property<bool>("LockoutEnabled")
.HasColumnType("boolean");
b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("timestamp with time zone");
b.Property<string>("NormalizedEmail")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("NormalizedUserName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("PasswordHash")
.HasColumnType("text");
b.Property<string>("PhoneNumber")
.HasColumnType("text");
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("boolean");
b.Property<string>("SecurityStamp")
.HasColumnType("text");
b.Property<bool>("TwoFactorEnabled")
.HasColumnType("boolean");
b.Property<string>("UserName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.HasKey("Id");
b.HasIndex("NormalizedEmail")
.HasDatabaseName("EmailIndex");
b.HasIndex("NormalizedUserName")
.IsUnique()
.HasDatabaseName("UserNameIndex");
b.ToTable("AspNetUsers", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<System.Guid>", b =>
{
b.HasOne("TeleWave.Infrastructure.Identity.AppRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b =>
{
b.HasOne("TeleWave.Infrastructure.Identity.AppUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b =>
{
b.HasOne("TeleWave.Infrastructure.Identity.AppUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<System.Guid>", b =>
{
b.HasOne("TeleWave.Infrastructure.Identity.AppRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("TeleWave.Infrastructure.Identity.AppUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<System.Guid>", b =>
{
b.HasOne("TeleWave.Infrastructure.Identity.AppUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,257 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace TeleWave.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class InitialCreate : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "AspNetRoles",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
IsSystem = table.Column<bool>(type: "boolean", nullable: false),
Name = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
NormalizedName = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
ConcurrencyStamp = table.Column<string>(type: "text", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetRoles", x => x.Id);
});
migrationBuilder.CreateTable(
name: "AspNetUsers",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
IsBlocked = table.Column<bool>(type: "boolean", nullable: false),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
UserName = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
NormalizedUserName = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
Email = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
NormalizedEmail = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
EmailConfirmed = table.Column<bool>(type: "boolean", nullable: false),
PasswordHash = table.Column<string>(type: "text", nullable: true),
SecurityStamp = table.Column<string>(type: "text", nullable: true),
ConcurrencyStamp = table.Column<string>(type: "text", nullable: true),
PhoneNumber = table.Column<string>(type: "text", nullable: true),
PhoneNumberConfirmed = table.Column<bool>(type: "boolean", nullable: false),
TwoFactorEnabled = table.Column<bool>(type: "boolean", nullable: false),
LockoutEnd = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
LockoutEnabled = table.Column<bool>(type: "boolean", nullable: false),
AccessFailedCount = table.Column<int>(type: "integer", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetUsers", x => x.Id);
});
migrationBuilder.CreateTable(
name: "RefreshTokens",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
UserId = table.Column<Guid>(type: "uuid", nullable: false),
TokenHash = table.Column<string>(type: "text", nullable: false),
ExpiresAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
RevokedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
ReplacedByTokenHash = table.Column<string>(type: "text", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_RefreshTokens", x => x.Id);
});
migrationBuilder.CreateTable(
name: "AspNetRoleClaims",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
RoleId = table.Column<Guid>(type: "uuid", nullable: false),
ClaimType = table.Column<string>(type: "text", nullable: true),
ClaimValue = table.Column<string>(type: "text", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetRoleClaims", x => x.Id);
table.ForeignKey(
name: "FK_AspNetRoleClaims_AspNetRoles_RoleId",
column: x => x.RoleId,
principalTable: "AspNetRoles",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "AspNetUserClaims",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
UserId = table.Column<Guid>(type: "uuid", nullable: false),
ClaimType = table.Column<string>(type: "text", nullable: true),
ClaimValue = table.Column<string>(type: "text", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetUserClaims", x => x.Id);
table.ForeignKey(
name: "FK_AspNetUserClaims_AspNetUsers_UserId",
column: x => x.UserId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "AspNetUserLogins",
columns: table => new
{
LoginProvider = table.Column<string>(type: "text", nullable: false),
ProviderKey = table.Column<string>(type: "text", nullable: false),
ProviderDisplayName = table.Column<string>(type: "text", nullable: true),
UserId = table.Column<Guid>(type: "uuid", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetUserLogins", x => new { x.LoginProvider, x.ProviderKey });
table.ForeignKey(
name: "FK_AspNetUserLogins_AspNetUsers_UserId",
column: x => x.UserId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "AspNetUserRoles",
columns: table => new
{
UserId = table.Column<Guid>(type: "uuid", nullable: false),
RoleId = table.Column<Guid>(type: "uuid", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetUserRoles", x => new { x.UserId, x.RoleId });
table.ForeignKey(
name: "FK_AspNetUserRoles_AspNetRoles_RoleId",
column: x => x.RoleId,
principalTable: "AspNetRoles",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_AspNetUserRoles_AspNetUsers_UserId",
column: x => x.UserId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "AspNetUserTokens",
columns: table => new
{
UserId = table.Column<Guid>(type: "uuid", nullable: false),
LoginProvider = table.Column<string>(type: "text", nullable: false),
Name = table.Column<string>(type: "text", nullable: false),
Value = table.Column<string>(type: "text", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetUserTokens", x => new { x.UserId, x.LoginProvider, x.Name });
table.ForeignKey(
name: "FK_AspNetUserTokens_AspNetUsers_UserId",
column: x => x.UserId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_AspNetRoleClaims_RoleId",
table: "AspNetRoleClaims",
column: "RoleId");
migrationBuilder.CreateIndex(
name: "RoleNameIndex",
table: "AspNetRoles",
column: "NormalizedName",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_AspNetUserClaims_UserId",
table: "AspNetUserClaims",
column: "UserId");
migrationBuilder.CreateIndex(
name: "IX_AspNetUserLogins_UserId",
table: "AspNetUserLogins",
column: "UserId");
migrationBuilder.CreateIndex(
name: "IX_AspNetUserRoles_RoleId",
table: "AspNetUserRoles",
column: "RoleId");
migrationBuilder.CreateIndex(
name: "EmailIndex",
table: "AspNetUsers",
column: "NormalizedEmail");
migrationBuilder.CreateIndex(
name: "UserNameIndex",
table: "AspNetUsers",
column: "NormalizedUserName",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_RefreshTokens_TokenHash",
table: "RefreshTokens",
column: "TokenHash",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_RefreshTokens_UserId",
table: "RefreshTokens",
column: "UserId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "AspNetRoleClaims");
migrationBuilder.DropTable(
name: "AspNetUserClaims");
migrationBuilder.DropTable(
name: "AspNetUserLogins");
migrationBuilder.DropTable(
name: "AspNetUserRoles");
migrationBuilder.DropTable(
name: "AspNetUserTokens");
migrationBuilder.DropTable(
name: "RefreshTokens");
migrationBuilder.DropTable(
name: "AspNetRoles");
migrationBuilder.DropTable(
name: "AspNetUsers");
}
}
}
@@ -0,0 +1,317 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using TeleWave.Infrastructure.Persistence;
#nullable disable
namespace TeleWave.Infrastructure.Migrations
{
[DbContext(typeof(AppDbContext))]
partial class AppDbContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.10")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<System.Guid>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("text");
b.Property<string>("ClaimValue")
.HasColumnType("text");
b.Property<Guid>("RoleId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("RoleId");
b.ToTable("AspNetRoleClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("text");
b.Property<string>("ClaimValue")
.HasColumnType("text");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("AspNetUserClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b =>
{
b.Property<string>("LoginProvider")
.HasColumnType("text");
b.Property<string>("ProviderKey")
.HasColumnType("text");
b.Property<string>("ProviderDisplayName")
.HasColumnType("text");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("LoginProvider", "ProviderKey");
b.HasIndex("UserId");
b.ToTable("AspNetUserLogins", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<System.Guid>", b =>
{
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.Property<Guid>("RoleId")
.HasColumnType("uuid");
b.HasKey("UserId", "RoleId");
b.HasIndex("RoleId");
b.ToTable("AspNetUserRoles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<System.Guid>", b =>
{
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.Property<string>("LoginProvider")
.HasColumnType("text");
b.Property<string>("Name")
.HasColumnType("text");
b.Property<string>("Value")
.HasColumnType("text");
b.HasKey("UserId", "LoginProvider", "Name");
b.ToTable("AspNetUserTokens", (string)null);
});
modelBuilder.Entity("TeleWave.Domain.Auth.RefreshToken", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset>("ExpiresAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("ReplacedByTokenHash")
.HasColumnType("text");
b.Property<DateTimeOffset?>("RevokedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("TokenHash")
.IsRequired()
.HasColumnType("text");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("TokenHash")
.IsUnique();
b.HasIndex("UserId");
b.ToTable("RefreshTokens");
});
modelBuilder.Entity("TeleWave.Infrastructure.Identity.AppRole", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("text");
b.Property<bool>("IsSystem")
.HasColumnType("boolean");
b.Property<string>("Name")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("NormalizedName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.HasKey("Id");
b.HasIndex("NormalizedName")
.IsUnique()
.HasDatabaseName("RoleNameIndex");
b.ToTable("AspNetRoles", (string)null);
});
modelBuilder.Entity("TeleWave.Infrastructure.Identity.AppUser", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<int>("AccessFailedCount")
.HasColumnType("integer");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("text");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<bool>("EmailConfirmed")
.HasColumnType("boolean");
b.Property<bool>("IsBlocked")
.HasColumnType("boolean");
b.Property<bool>("LockoutEnabled")
.HasColumnType("boolean");
b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("timestamp with time zone");
b.Property<string>("NormalizedEmail")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("NormalizedUserName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("PasswordHash")
.HasColumnType("text");
b.Property<string>("PhoneNumber")
.HasColumnType("text");
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("boolean");
b.Property<string>("SecurityStamp")
.HasColumnType("text");
b.Property<bool>("TwoFactorEnabled")
.HasColumnType("boolean");
b.Property<string>("UserName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.HasKey("Id");
b.HasIndex("NormalizedEmail")
.HasDatabaseName("EmailIndex");
b.HasIndex("NormalizedUserName")
.IsUnique()
.HasDatabaseName("UserNameIndex");
b.ToTable("AspNetUsers", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<System.Guid>", b =>
{
b.HasOne("TeleWave.Infrastructure.Identity.AppRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b =>
{
b.HasOne("TeleWave.Infrastructure.Identity.AppUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b =>
{
b.HasOne("TeleWave.Infrastructure.Identity.AppUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<System.Guid>", b =>
{
b.HasOne("TeleWave.Infrastructure.Identity.AppRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("TeleWave.Infrastructure.Identity.AppUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<System.Guid>", b =>
{
b.HasOne("TeleWave.Infrastructure.Identity.AppUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,24 @@
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Domain.Auth;
using TeleWave.Infrastructure.Identity;
namespace TeleWave.Infrastructure.Persistence;
/// <summary>
/// Корневой DbContext приложения: Identity-схема (пользователи/роли) + сущности домена
/// (добавляются по мере реализации фич).
/// </summary>
public class AppDbContext(DbContextOptions<AppDbContext> options)
: IdentityDbContext<AppUser, AppRole, Guid>(options),
IAppDbContext
{
public DbSet<RefreshToken> RefreshTokens => Set<RefreshToken>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly);
}
}
@@ -0,0 +1,14 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using TeleWave.Domain.Auth;
namespace TeleWave.Infrastructure.Persistence.Configurations;
public class RefreshTokenConfiguration : IEntityTypeConfiguration<RefreshToken>
{
public void Configure(EntityTypeBuilder<RefreshToken> builder)
{
builder.HasIndex(x => x.TokenHash).IsUnique();
builder.HasIndex(x => x.UserId);
}
}
@@ -0,0 +1,18 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
namespace TeleWave.Infrastructure.Persistence;
/// <summary>Применение EF Core-миграций при старте приложения (стратегия MVP — авто-миграции).</summary>
public static class MigrationExtensions
{
public static async Task ApplyMigrationsAsync(
this IServiceProvider services,
CancellationToken cancellationToken = default
)
{
await using var scope = services.CreateAsyncScope();
var dbContext = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await dbContext.Database.MigrateAsync(cancellationToken);
}
}

Some files were not shown because too many files have changed in this diff Show More