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:
@@ -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
@@ -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
|
||||||
@@ -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
@@ -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"]
|
||||||
@@ -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) (стек, архитектурные
|
||||||
|
правила, доменные инварианты, соглашения по коду).
|
||||||
|
|
||||||
|
## Лицензия
|
||||||
|
|
||||||
|
Не определена.
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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);
|
||||||
@@ -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<Program> в интеграционных тестах.</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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>;
|
||||||
+12
@@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+12
@@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+11
@@ -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>;
|
||||||
+22
@@ -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<T> 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);
|
||||||
|
}
|
||||||
+320
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+14
@@ -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
Reference in New Issue
Block a user