diff --git a/README.md b/README.md index e8cf9e7..91dae16 100644 --- a/README.md +++ b/README.md @@ -42,38 +42,36 @@ Self-hosted SSO / OIDC-сервис — аналог Keycloak / Яндекс ID. - `/revoke` - PKCE support -## Быстрый старт +## Быстрая установка (чистый сервер — всё включено) -### 1. Инфраструктура +**Одна команда — установит Node.js 22, Docker, сам сервис и запустит:** + +```bash +curl -fsSL https://github.com/ssomvk/sso-service/raw/main/install.sh | bash +``` + +Скрипт автоматически: +1. Определяет ОС (Ubuntu/Debian/RHEL/CentOS/Fedora) +2. Устанавливает **Docker** + **Docker Compose plugin** (если нет) +3. Устанавливает **Node.js 22 LTS** (через NodeSource или nvm) +4. Скачивает SSO Service в `~/sso-service` +5. Генерирует `.env` со случайными JWT-секретами +6. Запускает PostgreSQL, Redis, RabbitMQ (ждёт готовности) +7. Устанавливает npm-зависимости, Prisma client, применяет миграции +8. Сидирует БД (администратор и тестовый OAuth-клиент) +9. Собирает и запускает сервис (PM2 или background) + +**Через 2-3 минуты сервис доступен:** http://localhost:3000 + +### Ручная установка (если нужен контроль) ```bash docker compose up -d -``` - -### 2. База данных - -```bash npx prisma db push -``` - -### 3. Тестовые данные - -```bash npx ts-node prisma/seed.ts -``` - -Создаёт: -- **Admin:** `admin@sso.local` / `admin123!` -- **OAuth Client:** `test-client` (секрет в консоли) - -### 4. Запуск - -```bash npm run start:dev ``` -Сервис: `http://localhost:3000` - ## API Endpoints | Method | Path | Auth | Description | diff --git a/install.sh b/install.sh index 70f9f2b..25a7bd3 100644 --- a/install.sh +++ b/install.sh @@ -1,9 +1,10 @@ #!/usr/bin/env bash set -euo pipefail -REPO="lendry/sso-mvk" +REPO="ssomvk/sso-service" BRANCH="main" INSTALL_DIR="${INSTALL_DIR:-$HOME/sso-service}" +SKIP_PREREQS="${SKIP_PREREQS:-false}" RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; CYAN='\033[0;36m'; NC='\033[0m' info() { echo -e "${GREEN}✔${NC} $1"; } @@ -12,107 +13,232 @@ error() { echo -e "${RED}✘${NC} $1"; exit 1; } step() { echo -e "\n${CYAN}━━━ $1 ━━━${NC}"; } echo -e "${CYAN}" -echo " ╔═══════════════════════════════════════╗" -echo " ║ SSO Service Installer ║" -echo " ╚═══════════════════════════════════════╝" +echo " ╔══════════════════════════════════════════════╗" +echo " ║ SSO Service — Auto Installer ║" +echo " ║ https://github.com/${REPO} ║" +echo " ╚══════════════════════════════════════════════╝" echo -e "${NC}" -# ─── Prerequisites ────────────────────────────────────────────── -step "1/7 Checking prerequisites" -command -v node >/dev/null 2>&1 || error "Node.js required → https://nodejs.org" -command -v npm >/dev/null 2>&1 || error "npm required" -command -v docker compose >/dev/null 2>&1 || error "Docker Compose required" -info "node $(node -v), npm $(npm -v), docker compose found" +# ─── Проверка root ────────────────────────────────────────────── +if [ "$(id -u)" -eq 0 ]; then + SUDO="" +else + SUDO="sudo" +fi -# ─── Clone / Pull ─────────────────────────────────────────────── -step "2/7 Downloading SSO Service" +# ─── Detecting OS ──────────────────────────────────────────────── +step "Detecting operating system" +OS="" +if [ -f /etc/os-release ]; then + . /etc/os-release + OS="$ID" +elif command -v lsb_release &>/dev/null; then + OS="$(lsb_release -si | tr '[:upper:]' '[:lower:]')" +else + OS="$(uname -s)" +fi +info "Detected: ${OS} ($(uname -m))" + +# ─── 1. Docker ────────────────────────────────────────────────── +install_docker() { + step "1/8 Installing Docker" + if command -v docker &>/dev/null; then + info "Docker already installed ($(docker --version))" + else + warn "Docker not found — installing..." + if command -v apt-get &>/dev/null; then + curl -fsSL https://get.docker.com | $SUDO sh + elif command -v yum &>/dev/null; then + curl -fsSL https://get.docker.com | $SUDO sh + elif command -v dnf &>/dev/null; then + curl -fsSL https://get.docker.com | $SUDO sh + else + error "Unsupported package manager. Install Docker manually: https://docs.docker.com/engine/install/" + fi + $SUDO usermod -aG docker "$USER" 2>/dev/null || true + info "Docker installed." + fi + + # Ensure Docker daemon is running + if ! docker info &>/dev/null; then + warn "Starting Docker daemon..." + $SUDO systemctl enable --now docker 2>/dev/null || $SUDO dockerd &>/dev/null & + sleep 3 + fi + info "Docker daemon is running" + + if ! docker compose version &>/dev/null; then + warn "Docker Compose not found — installing plugin..." + $SUDO mkdir -p /usr/local/lib/docker/cli-plugins + $SUDO curl -fsSL "https://github.com/docker/compose/releases/latest/download/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/lib/docker/cli-plugins/docker-compose + $SUDO chmod +x /usr/local/lib/docker/cli-plugins/docker-compose + fi + info "Docker Compose: $(docker compose version 2>&1)" +} + +# ─── 2. Node.js ───────────────────────────────────────────────── +install_node() { + step "2/8 Installing Node.js" + if command -v node &>/dev/null; then + NODE_MAJOR=$(node -v | sed 's/v//' | cut -d. -f1) + if [ "$NODE_MAJOR" -lt 22 ]; then + warn "Node.js $(node -v) is too old — upgrading to v22..." + else + info "Node.js $(node -v) detected" + return 0 + fi + fi + + if ! command -v node &>/dev/null || [ "$NODE_MAJOR" -lt 22 ]; then + warn "Installing Node.js 22 LTS..." + if command -v apt-get &>/dev/null; then + curl -fsSL https://deb.nodesource.com/setup_22.x | $SUDO bash - + $SUDO apt-get install -y nodejs + elif command -v dnf &>/dev/null; then + curl -fsSL https://rpm.nodesource.com/setup_22.x | $SUDO bash - + $SUDO dnf install -y nodejs + elif command -v yum &>/dev/null; then + curl -fsSL https://rpm.nodesource.com/setup_22.x | $SUDO bash - + $SUDO yum install -y nodejs + else + warn "Package manager not recognised — trying nvm..." + curl -fsSL https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash + export NVM_DIR="$HOME/.nvm" + [ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" + nvm install 22 + nvm alias default 22 + fi + fi + info "Node.js $(node -v) ready" +} + +# ─── 3. Install prerequisites if needed ────────────────────────── +if [ "$SKIP_PREREQS" != "true" ]; then + install_docker + install_node +else + step "1-2/8 Skipping prerequisites (SKIP_PREREQS=true)" +fi + +# ─── 4. Download project ───────────────────────────────────────── +step "3/8 Downloading SSO Service" if [ -d "$INSTALL_DIR/.git" ]; then cd "$INSTALL_DIR" git pull --ff-only 2>/dev/null && info "Updated existing installation" || warn "Could not git pull, continuing" else mkdir -p "$INSTALL_DIR" if command -v git &>/dev/null; then - git clone --depth 1 "https://github.com/$REPO.git" "$INSTALL_DIR" + git clone --depth 1 "https://github.com/$REPO.git" "$INSTALL_DIR" 2>&1 else + warn "git not found, downloading archive..." curl -fsSL "https://github.com/$REPO/archive/refs/heads/$BRANCH.tar.gz" | tar xz --strip=1 -C "$INSTALL_DIR" fi info "Downloaded to $INSTALL_DIR" fi cd "$INSTALL_DIR" -# ─── .env ──────────────────────────────────────────────────────── -step "3/7 Configuring environment" +# ─── 5. .env ───────────────────────────────────────────────────── +step "4/8 Configuring environment" if [ ! -f .env ]; then cp .env.example .env - sed -i "s/change-me-access-secret-at-least-32-chars/$(openssl rand -hex 32)/" .env - sed -i "s/change-me-refresh-secret-at-least-32-chars/$(openssl rand -hex 32)/" .env - info "Created .env with random secrets" + # Генерация случайных JWT-секретов (32 байта hex = 64 символа) + JWT_ACCESS=$(openssl rand -hex 32 2>/dev/null || node -e "process.stdout.write(require('crypto').randomBytes(32).toString('hex'))") + JWT_REFRESH=$(openssl rand -hex 32 2>/dev/null || node -e "process.stdout.write(require('crypto').randomBytes(32).toString('hex'))") + if [ "$(uname -s)" = "Darwin" ]; then + sed -i '' "s/change-me-access-secret-at-least-32-chars/$JWT_ACCESS/" .env + sed -i '' "s/change-me-refresh-secret-at-least-32-chars/$JWT_REFRESH/" .env + else + sed -i "s/change-me-access-secret-at-least-32-chars/$JWT_ACCESS/" .env + sed -i "s/change-me-refresh-secret-at-least-32-chars/$JWT_REFRESH/" .env + fi + info "Created .env with random JWT secrets" else info ".env already exists, keeping it" fi -# shellcheck source=/dev/null set -a; source .env; set +a -# ─── Docker ────────────────────────────────────────────────────── -step "4/7 Starting infrastructure (PostgreSQL, Redis, RabbitMQ)" -docker compose up -d 2>&1 +# ─── 6. Docker infrastructure ──────────────────────────────────── +step "5/8 Starting infrastructure (PostgreSQL, Redis, RabbitMQ)" +$SUDO docker compose up -d 2>&1 info "Waiting for PostgreSQL..." for i in $(seq 1 30); do - if docker compose exec -T postgres pg_isready -U "${POSTGRES_USER}" -d "${POSTGRES_DB}" &>/dev/null; then + if $SUDO docker compose exec -T postgres pg_isready -U "${POSTGRES_USER}" -d "${POSTGRES_DB}" &>/dev/null; then info "PostgreSQL is ready" break fi - [ "$i" -eq 30 ] && error "PostgreSQL did not start in time. Check: docker compose logs postgres" + [ "$i" -eq 30 ] && error "PostgreSQL did not start in time. Check: $SUDO docker compose logs postgres" sleep 2 done -# ─── Dependencies & Prisma ────────────────────────────────────── -step "5/7 Installing dependencies and generating Prisma client" +info "Waiting for Redis..." +for i in $(seq 1 15); do + if $SUDO docker compose exec -T redis redis-cli ping 2>/dev/null | grep -q PONG; then + info "Redis is ready" + break + fi + [ "$i" -eq 15 ] && warn "Redis may not be ready yet, continuing..." + sleep 2 +done + +# ─── 7. Dependencies & Prisma ──────────────────────────────────── +step "6/8 Installing dependencies" npm ci --no-audit --no-fund 2>&1 | tail -1 +info "npm dependencies installed" + npx prisma generate 2>&1 | tail -1 +info "Prisma client generated" export DATABASE_URL npx prisma db push --skip-generate 2>&1 info "Database schema applied" -npx ts-node prisma/seed.ts 2>&1 +# ─── 8. Seed ───────────────────────────────────────────────────── +step "7/8 Seeding database" +npx ts-node -P tsconfig.json prisma/seed.ts 2>&1 info "Seed data created" -# ─── Build ────────────────────────────────────────────────────── -echo "" +# ─── 9. Build & Start ──────────────────────────────────────────── +step "8/8 Building and starting SSO Service" npm run build 2>&1 | tail -3 info "Application built" -# ─── Start ────────────────────────────────────────────────────── -step "6/7 Starting SSO Service" if command -v pm2 &>/dev/null; then pm2 delete sso-service 2>/dev/null || true pm2 start npm --name "sso-service" -- run start:prod - pm2 save + pm2 save 2>/dev/null || true info "Started with PM2" else - PORT="${PORT:-3000}" nohup node dist/main > sso.log 2>&1 & - echo $! > .sso.pid - info "Started in background (PID $(cat .sso.pid)), logs: sso.log" + PORT="${PORT:-3000}" nohup node dist/main > "$INSTALL_DIR/sso.log" 2>&1 & + echo $! > "$INSTALL_DIR/.sso.pid" + info "Started in background (PID $(cat "$INSTALL_DIR/.sso.pid")) — logs: $INSTALL_DIR/sso.log" fi sleep 3 -if curl -sfo /dev/null http://localhost:"${PORT:-3000}"/metrics 2>/dev/null; then +if curl -sfo /dev/null "http://localhost:${PORT:-3000}/metrics" 2>/dev/null; then info "Service is running on http://localhost:${PORT:-3000}" else - warn "Service may still be starting... check sso.log" + warn "Service may still be starting... check: $INSTALL_DIR/sso.log" fi # ─── Done ──────────────────────────────────────────────────────── -step "7/7 Installation complete!" echo "" -echo -e " ${GREEN}Web panel:${NC} http://localhost:${PORT:-3000}" -echo -e " ${GREEN}Admin login:${NC} admin@sso.local / admin123!" -echo -e " ${GREEN}Metrics:${NC} http://localhost:${PORT:-3000}/metrics" -echo -e " ${GREEN}RabbitMQ UI:${NC} http://localhost:15672 (sso / sso_secret)" +echo -e "${GREEN}════════════════════════════════════════════════${NC}" +echo -e "${GREEN} SSO Service installed and running!${NC}" +echo -e "${GREEN}════════════════════════════════════════════════${NC}" echo "" -echo -e " ${YELLOW}To stop:${NC} kill \$(cat $INSTALL_DIR/.sso.pid) (or pm2 stop sso-service)" -echo -e " ${YELLOW}To update:${NC} cd $INSTALL_DIR && git pull && npm ci && npm run build && npm run start:prod" +echo -e " ${CYAN}Web panel:${NC} http://localhost:${PORT:-3000}" +echo -e " ${CYAN}Admin login:${NC} admin@sso.local / admin123!" +echo -e " ${CYAN}Metrics:${NC} http://localhost:${PORT:-3000}/metrics" +echo -e " ${CYAN}RabbitMQ UI:${NC} http://localhost:15672 (${RABBITMQ_USER:-sso} / ${RABBITMQ_PASSWORD:-sso_secret})" +echo "" +echo -e " ${YELLOW}Install dir:${NC} $INSTALL_DIR" +echo -e " ${YELLOW}Log file:${NC} $INSTALL_DIR/sso.log" +echo "" +echo -e " ${YELLOW}Stop:${NC} $(command -v pm2 &>/dev/null && echo 'pm2 stop sso-service' || echo "kill \$(cat $INSTALL_DIR/.sso.pid)")" +echo -e " ${YELLOW}Update:${NC} cd $INSTALL_DIR && git pull && npm ci && npm run build && npm run start:prod" +echo -e " ${YELLOW}Re-run:${NC} curl -fsSL https://github.com/${REPO}/raw/${BRANCH}/install.sh | bash" +echo "" +echo -e " ${GREEN}Open http://localhost:${PORT:-3000} in your browser${NC}" echo "" diff --git a/prisma/schema.prisma b/prisma/schema.prisma index ab26167..7400022 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -37,6 +37,7 @@ model User { auditLogs AuditLog[] verificationCodes VerificationCode[] qrSessions QrSession[] + authorizationCodes AuthorizationCode[] @@map("users") } @@ -124,6 +125,24 @@ model QrSession { @@map("qr_sessions") } +model AuthorizationCode { + id String @id @default(uuid()) @db.Uuid + codeHash String @unique @map("code_hash") + clientId String @map("client_id") + userId String? @map("user_id") @db.Uuid + redirectUri String @map("redirect_uri") + codeChallenge String? @map("code_challenge") + codeChallengeMethod String? @map("code_challenge_method") + scope String? + expiresAt DateTime @map("expires_at") + usedAt DateTime? @map("used_at") + createdAt DateTime @default(now()) @map("created_at") + + user User? @relation(fields: [userId], references: [id], onDelete: SetNull) + + @@map("authorization_codes") +} + model AuditLog { id String @id @default(uuid()) @db.Uuid userId String? @map("user_id") @db.Uuid diff --git a/src/admin/admin.controller.ts b/src/admin/admin.controller.ts index 3cabba4..c73a4b4 100644 --- a/src/admin/admin.controller.ts +++ b/src/admin/admin.controller.ts @@ -7,18 +7,20 @@ import { Body, Param, Query, - UseGuards, + Req, + ParseUUIDPipe, + ParseIntPipe, + DefaultValuePipe, } from '@nestjs/common'; -import { AuthGuard } from '@nestjs/passport'; +import type { Request } from 'express'; import { Role } from '@prisma/client'; import { Roles } from '../common/decorators/roles.decorator'; -import { RolesGuard } from '../common/guards/roles.guard'; +import { RequestWithUser } from '../common/types'; import { AdminService } from './admin.service'; import { CreateUserDto, UpdateUserDto } from './dto/create-user.dto'; import { CreateClientDto, UpdateClientDto } from './dto/create-client.dto'; @Controller('admin') -@UseGuards(AuthGuard('jwt'), RolesGuard) @Roles(Role.ADMIN) export class AdminController { constructor(private readonly adminService: AdminService) {} @@ -30,49 +32,73 @@ export class AdminController { @Get('users') async listUsers( - @Query('page') page?: string, - @Query('limit') limit?: string, + @Query( + 'page', + new DefaultValuePipe(1), + new ParseIntPipe({ optional: true }), + ) + page?: number, + @Query( + 'limit', + new DefaultValuePipe(20), + new ParseIntPipe({ optional: true }), + ) + limit?: number, ) { - return this.adminService.listUsers( - page ? parseInt(page, 10) : 1, - limit ? parseInt(limit, 10) : 20, - ); + return this.adminService.listUsers(page ?? 1, limit ?? 20); } @Get('users/:id') - async getUser(@Param('id') id: string) { + async getUser(@Param('id', ParseUUIDPipe) id: string) { return this.adminService.getUser(id); } @Post('users') - async createUser(@Body() dto: CreateUserDto) { - return this.adminService.createUser(dto); + async createUser(@Body() dto: CreateUserDto, @Req() req: Request) { + const actorId = (req as RequestWithUser).user?.sub; + return this.adminService.createUser(dto, actorId); } @Put('users/:id') - async updateUser(@Param('id') id: string, @Body() dto: UpdateUserDto) { - return this.adminService.updateUser(id, dto); + async updateUser( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: UpdateUserDto, + @Req() req: Request, + ) { + const actorId = (req as RequestWithUser).user?.sub; + return this.adminService.updateUser(id, dto, actorId); } @Delete('users/:id') - async deleteUser(@Param('id') id: string) { - await this.adminService.deleteUser(id); + async deleteUser( + @Param('id', ParseUUIDPipe) id: string, + @Req() req: Request, + ) { + const actorId = (req as RequestWithUser).user?.sub; + await this.adminService.deleteUser(id, actorId); return { deleted: true }; } @Get('clients') async listClients( - @Query('page') page?: string, - @Query('limit') limit?: string, + @Query( + 'page', + new DefaultValuePipe(1), + new ParseIntPipe({ optional: true }), + ) + page?: number, + @Query( + 'limit', + new DefaultValuePipe(20), + new ParseIntPipe({ optional: true }), + ) + limit?: number, ) { - return this.adminService.listClients( - page ? parseInt(page, 10) : 1, - limit ? parseInt(limit, 10) : 20, - ); + return this.adminService.listClients(page ?? 1, limit ?? 20); } @Get('clients/:id') - async getClient(@Param('id') id: string) { + async getClient(@Param('id', ParseUUIDPipe) id: string) { return this.adminService.getClient(id); } @@ -82,12 +108,15 @@ export class AdminController { } @Put('clients/:id') - async updateClient(@Param('id') id: string, @Body() dto: UpdateClientDto) { + async updateClient( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: UpdateClientDto, + ) { return this.adminService.updateClient(id, dto); } @Delete('clients/:id') - async deleteClient(@Param('id') id: string) { + async deleteClient(@Param('id', ParseUUIDPipe) id: string) { await this.adminService.deleteClient(id); return { deleted: true }; } diff --git a/src/admin/admin.service.ts b/src/admin/admin.service.ts index a9136f0..0be365a 100644 --- a/src/admin/admin.service.ts +++ b/src/admin/admin.service.ts @@ -1,7 +1,12 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; +import { + Injectable, + NotFoundException, + ForbiddenException, +} from '@nestjs/common'; import * as argon2 from 'argon2'; import { randomBytes } from 'node:crypto'; import { PinoLogger } from 'nestjs-pino'; +import { Role } from '@prisma/client'; import { PrismaService } from '../prisma/prisma.service'; import { CreateUserDto, UpdateUserDto } from './dto/create-user.dto'; import { CreateClientDto, UpdateClientDto } from './dto/create-client.dto'; @@ -61,21 +66,35 @@ export class AdminService { return user; } - async createUser(dto: CreateUserDto) { + async createUser(dto: CreateUserDto, actorId?: string) { const passwordHash = await argon2.hash(dto.password); - return this.prisma.user.create({ - data: { - email: dto.email, - passwordHash, - displayName: dto.displayName, - phone: dto.phone, - role: dto.role ?? 'USER', - }, - select: { id: true, email: true, displayName: true, role: true }, + const role = dto.role === Role.ADMIN ? Role.ADMIN : Role.USER; + + return this.prisma.$transaction(async (tx) => { + const user = await tx.user.create({ + data: { + email: dto.email, + passwordHash, + displayName: dto.displayName, + phone: dto.phone, + role, + }, + select: { id: true, email: true, displayName: true, role: true }, + }); + + await tx.auditLog.create({ + data: { + userId: user.id, + action: 'ADMIN_CREATED_USER', + details: { by: actorId, role }, + }, + }); + + return user; }); } - async updateUser(id: string, dto: UpdateUserDto) { + async updateUser(id: string, dto: UpdateUserDto, actorId?: string) { const user = await this.prisma.user.findUnique({ where: { id } }); if (!user) throw new NotFoundException('User not found'); @@ -83,27 +102,57 @@ export class AdminService { if (dto.email) data.email = dto.email; if (dto.displayName) data.displayName = dto.displayName; if (dto.phone) data.phone = dto.phone; - if (dto.role) data.role = dto.role; if (dto.isActive !== undefined) data.isActive = dto.isActive; if (dto.password) data.passwordHash = await argon2.hash(dto.password); + if (dto.role) { + if (dto.role === Role.ADMIN && user.role !== Role.ADMIN) { + throw new ForbiddenException('Cannot escalate user to ADMIN'); + } + if (dto.role === Role.USER && user.role === Role.ADMIN) { + throw new ForbiddenException('Cannot demote ADMIN to USER'); + } + data.role = dto.role; + } - return this.prisma.user.update({ - where: { id }, - data, - select: { - id: true, - email: true, - displayName: true, - role: true, - isActive: true, - }, + return this.prisma.$transaction(async (tx) => { + const updated = await tx.user.update({ + where: { id }, + data, + select: { + id: true, + email: true, + displayName: true, + role: true, + isActive: true, + }, + }); + + await tx.auditLog.create({ + data: { + userId: id, + action: 'ADMIN_UPDATED_USER', + details: { by: actorId, changes: Object.keys(data) }, + }, + }); + + return updated; }); } - async deleteUser(id: string) { + async deleteUser(id: string, actorId?: string) { const user = await this.prisma.user.findUnique({ where: { id } }); if (!user) throw new NotFoundException('User not found'); - await this.prisma.user.delete({ where: { id } }); + + await this.prisma.$transaction(async (tx) => { + await tx.user.delete({ where: { id } }); + await tx.auditLog.create({ + data: { + userId: id, + action: 'ADMIN_DELETED_USER', + details: { by: actorId, email: user.email }, + }, + }); + }); } async listClients(page = 1, limit = 20) { @@ -159,7 +208,6 @@ export class AdminService { const data: Record = {}; if (dto.name) data.name = dto.name; if (dto.description !== undefined) data.description = dto.description; - if (dto.clientSecret) data.clientSecret = dto.clientSecret; if (dto.redirectUris) data.redirectUris = dto.redirectUris; if (dto.grants) data.grants = dto.grants; if (dto.isConfidential !== undefined) diff --git a/src/admin/dto/create-client.dto.ts b/src/admin/dto/create-client.dto.ts index dfd980a..7b2405e 100644 --- a/src/admin/dto/create-client.dto.ts +++ b/src/admin/dto/create-client.dto.ts @@ -1,4 +1,18 @@ -import { IsString, IsOptional, IsArray, IsBoolean } from 'class-validator'; +import { + IsString, + IsOptional, + IsArray, + IsBoolean, + IsUrl, + IsEnum, + ArrayMinSize, +} from 'class-validator'; + +export enum GrantType { + AUTHORIZATION_CODE = 'authorization_code', + REFRESH_TOKEN = 'refresh_token', + CLIENT_CREDENTIALS = 'client_credentials', +} export class CreateClientDto { @IsString() @@ -16,9 +30,13 @@ export class CreateClientDto { clientSecret?: string; @IsArray() + @ArrayMinSize(1) + @IsUrl({ require_tld: false }, { each: true }) redirectUris!: string[]; @IsArray() + @ArrayMinSize(1) + @IsEnum(GrantType, { each: true }) grants!: string[]; @IsOptional() @@ -45,10 +63,12 @@ export class UpdateClientDto { @IsOptional() @IsArray() + @IsUrl({ require_tld: false }, { each: true }) redirectUris?: string[]; @IsOptional() @IsArray() + @IsEnum(GrantType, { each: true }) grants?: string[]; @IsOptional() diff --git a/src/admin/dto/create-user.dto.ts b/src/admin/dto/create-user.dto.ts index 72128fd..f8092a6 100644 --- a/src/admin/dto/create-user.dto.ts +++ b/src/admin/dto/create-user.dto.ts @@ -4,6 +4,7 @@ import { MinLength, IsOptional, IsEnum, + IsBoolean, } from 'class-validator'; import { Role } from '@prisma/client'; @@ -51,5 +52,6 @@ export class UpdateUserDto { role?: Role; @IsOptional() + @IsBoolean() isActive?: boolean; } diff --git a/src/app.module.ts b/src/app.module.ts index 8333e9a..7eb3a9d 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -2,6 +2,7 @@ import { Module } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; import { ThrottlerModule } from '@nestjs/throttler'; import { APP_FILTER, APP_GUARD } from '@nestjs/core'; +import { ThrottlerGuard } from '@nestjs/throttler'; import { LoggerModule } from 'nestjs-pino'; import { PrismaModule } from './prisma/prisma.module'; import { AuthModule } from './auth/auth.module'; @@ -75,6 +76,7 @@ import appConfig from './config/configuration'; ], providers: [ { provide: APP_FILTER, useClass: AllExceptionsFilter }, + { provide: APP_GUARD, useClass: ThrottlerGuard }, { provide: APP_GUARD, useClass: JwtAuthGuard }, { provide: APP_GUARD, useClass: RolesGuard }, ], diff --git a/src/auth/auth.controller.ts b/src/auth/auth.controller.ts index bec2c73..19c1133 100644 --- a/src/auth/auth.controller.ts +++ b/src/auth/auth.controller.ts @@ -6,14 +6,19 @@ import { HttpCode, HttpStatus, Req, - UseGuards, } from '@nestjs/common'; import { Throttle } from '@nestjs/throttler'; -import { AuthGuard } from '@nestjs/passport'; import type { Request } from 'express'; import { Public } from '../common/decorators/public.decorator'; import { RequestWithUser } from '../common/types'; import { AuthService } from './auth.service'; +import { RegisterDto } from './dto/register.dto'; +import { LoginDto } from './dto/login.dto'; +import { LoginEmailCodeDto } from './dto/login-email-code.dto'; +import { LoginPhoneCodeDto } from './dto/login-phone-code.dto'; +import { SendEmailCodeDto, SendPhoneCodeDto } from './dto/send-code.dto'; +import { QrPollDto } from './dto/qr-poll.dto'; +import { UpdateProfileDto } from './dto/update-profile.dto'; @Controller('auth') export class AuthController { @@ -21,34 +26,25 @@ export class AuthController { @Public() @Post('register') - async register( - @Body() body: { email: string; password: string; displayName?: string }, - @Req() req: Request, - ) { - return this.authService.register(body, req.ip, req.headers['user-agent']); + async register(@Body() dto: RegisterDto, @Req() req: Request) { + return this.authService.register(dto, req.ip, req.headers['user-agent']); } @Public() @Throttle({ default: { ttl: 60000, limit: 5 } }) @HttpCode(HttpStatus.OK) @Post('login') - async login( - @Body() body: { email: string; password: string }, - @Req() req: Request, - ) { - return this.authService.login(body, req.ip, req.headers['user-agent']); + async login(@Body() dto: LoginDto, @Req() req: Request) { + return this.authService.login(dto, req.ip, req.headers['user-agent']); } @Public() @Post('login/email-code') @HttpCode(HttpStatus.OK) - async loginByEmailCode( - @Body() body: { email: string; code: string }, - @Req() req: Request, - ) { + async loginByEmailCode(@Body() dto: LoginEmailCodeDto, @Req() req: Request) { return this.authService.loginByEmailCode( - body.email, - body.code, + dto.email, + dto.code, req.ip, req.headers['user-agent'], ); @@ -57,13 +53,10 @@ export class AuthController { @Public() @Post('login/phone-code') @HttpCode(HttpStatus.OK) - async loginByPhoneCode( - @Body() body: { phone: string; code: string }, - @Req() req: Request, - ) { + async loginByPhoneCode(@Body() dto: LoginPhoneCodeDto, @Req() req: Request) { return this.authService.loginByPhoneCode( - body.phone, - body.code, + dto.phone, + dto.code, req.ip, req.headers['user-agent'], ); @@ -72,16 +65,16 @@ export class AuthController { @Public() @Post('send-email-code') @HttpCode(HttpStatus.OK) - async sendEmailCode(@Body() body: { email: string }) { - await this.authService.sendEmailCode(body.email); + async sendEmailCode(@Body() dto: SendEmailCodeDto) { + await this.authService.sendEmailCode(dto.email); return { sent: true }; } @Public() @Post('send-phone-code') @HttpCode(HttpStatus.OK) - async sendPhoneCode(@Body() body: { phone: string }) { - await this.authService.sendPhoneCode(body.phone); + async sendPhoneCode(@Body() dto: SendPhoneCodeDto) { + await this.authService.sendPhoneCode(dto.phone); return { sent: true }; } @@ -95,8 +88,8 @@ export class AuthController { @Public() @Post('qr/poll') @HttpCode(HttpStatus.OK) - async pollQr(@Body() body: { sessionId: string }) { - return this.authService.pollQrSession(body.sessionId); + async pollQr(@Body() dto: QrPollDto) { + return this.authService.pollQrSession(dto.sessionId); } @Public() @@ -120,21 +113,16 @@ export class AuthController { await this.authService.logout(refreshToken); } - @UseGuards(AuthGuard('jwt')) @Get('profile') async profile(@Req() req: Request) { const user = (req as RequestWithUser).user; return this.authService.getProfile(user.sub); } - @UseGuards(AuthGuard('jwt')) @Post('profile/update') @HttpCode(HttpStatus.OK) - async updateProfile( - @Body() body: { displayName?: string; avatarUrl?: string; phone?: string }, - @Req() req: Request, - ) { + async updateProfile(@Body() dto: UpdateProfileDto, @Req() req: Request) { const user = (req as RequestWithUser).user; - return this.authService.updateProfile(user.sub, body); + return this.authService.updateProfile(user.sub, dto); } } diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index 91967e8..ed57100 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -40,21 +40,25 @@ export class AuthService { const passwordHash = await argon2.hash(dto.password); - const user = await this.prisma.user.create({ - data: { - email: dto.email, - passwordHash, - displayName: dto.displayName, - }, - }); + const user = await this.prisma.$transaction(async (tx) => { + const u = await tx.user.create({ + data: { + email: dto.email, + passwordHash, + displayName: dto.displayName, + }, + }); - await this.prisma.auditLog.create({ - data: { - userId: user.id, - action: 'USER_REGISTERED', - ip, - userAgent, - }, + await tx.auditLog.create({ + data: { + userId: u.id, + action: 'USER_REGISTERED', + ip, + userAgent, + }, + }); + + return u; }); this.eventsService.emit('auth.user.registered', { @@ -199,17 +203,50 @@ export class AuthService { throw new UnauthorizedException('Session expired'); } - await this.prisma.refreshToken.update({ - where: { id: stored.id }, - data: { revoked: true }, + const result = await this.prisma.$transaction(async (tx) => { + await tx.refreshToken.update({ + where: { id: stored.id }, + data: { revoked: true }, + }); + + const now = new Date(); + const refreshExpiresIn = this.configService.get( + 'app.jwt.refreshExpiresIn', + )!; + const refreshMs = this.parseDuration(refreshExpiresIn); + + const session = await tx.session.create({ + data: { + userId: stored.session.userId, + clientId: stored.session.clientId, + ip: ip ?? stored.session.ip, + userAgent: userAgent ?? stored.session.userAgent, + expiresAt: new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000), + lastUsedAt: now, + }, + }); + + const newRefreshToken = this.generateRefreshToken(); + const newRefreshHash = this.hashToken(newRefreshToken); + + await tx.refreshToken.create({ + data: { + tokenHash: newRefreshHash, + sessionId: session.id, + expiresAt: new Date(now.getTime() + refreshMs), + }, + }); + + return { sessionId: session.id, refreshToken: newRefreshToken }; }); - return this.createSession( - stored.session.userId, - stored.session.clientId ?? undefined, - ip ?? stored.session.ip ?? undefined, - userAgent ?? stored.session.userAgent ?? undefined, - ); + const accessToken = await this.generateAccessToken(stored.session.userId); + + return { + accessToken, + refreshToken: result.refreshToken, + sessionId: result.sessionId, + }; } async logout(refreshToken: string) { @@ -220,17 +257,19 @@ export class AuthService { }); if (stored) { - await this.prisma.refreshToken.update({ - where: { id: stored.id }, - data: { revoked: true }, - }); - - if (stored.session) { - await this.prisma.session.update({ - where: { id: stored.session.id }, - data: { isActive: false }, + await this.prisma.$transaction(async (tx) => { + await tx.refreshToken.update({ + where: { id: stored.id }, + data: { revoked: true }, }); - } + + if (stored.session) { + await tx.session.update({ + where: { id: stored.session.id }, + data: { isActive: false }, + }); + } + }); } } @@ -275,38 +314,40 @@ export class AuthService { ip?: string, userAgent?: string, ) { - const now = new Date(); - const sessionTTL = 7 * 24 * 60 * 60 * 1000; - - const session = await this.prisma.session.create({ - data: { - userId, - clientId, - ip, - userAgent, - expiresAt: new Date(now.getTime() + sessionTTL), - lastUsedAt: now, - }, - }); - const accessToken = await this.generateAccessToken(userId); const refreshToken = this.generateRefreshToken(); const refreshTokenHash = this.hashToken(refreshToken); - const refreshExpiresIn = this.configService.get( 'app.jwt.refreshExpiresIn', )!; const refreshMs = this.parseDuration(refreshExpiresIn); - await this.prisma.refreshToken.create({ - data: { - tokenHash: refreshTokenHash, - sessionId: session.id, - expiresAt: new Date(now.getTime() + refreshMs), - }, + const now = new Date(); + + const { id: sessionId } = await this.prisma.$transaction(async (tx) => { + const session = await tx.session.create({ + data: { + userId, + clientId, + ip, + userAgent, + expiresAt: new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000), + lastUsedAt: now, + }, + }); + + await tx.refreshToken.create({ + data: { + tokenHash: refreshTokenHash, + sessionId: session.id, + expiresAt: new Date(now.getTime() + refreshMs), + }, + }); + + return { id: session.id }; }); - return { accessToken, refreshToken, sessionId: session.id }; + return { accessToken, refreshToken, sessionId }; } private async generateAccessToken(userId: string): Promise { @@ -317,8 +358,7 @@ export class AuthService { const options: JwtSignOptions = { secret: this.configService.get('app.jwt.accessSecret'), - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - expiresIn: this.configService.get('app.jwt.accessExpiresIn'), + expiresIn: this.configService.get('app.jwt.accessExpiresIn'), }; return this.jwtService.signAsync( { diff --git a/src/auth/dto/login-email-code.dto.ts b/src/auth/dto/login-email-code.dto.ts new file mode 100644 index 0000000..97f9bcf --- /dev/null +++ b/src/auth/dto/login-email-code.dto.ts @@ -0,0 +1,9 @@ +import { IsEmail, IsString } from 'class-validator'; + +export class LoginEmailCodeDto { + @IsEmail() + email!: string; + + @IsString() + code!: string; +} diff --git a/src/auth/dto/login-phone-code.dto.ts b/src/auth/dto/login-phone-code.dto.ts new file mode 100644 index 0000000..f392079 --- /dev/null +++ b/src/auth/dto/login-phone-code.dto.ts @@ -0,0 +1,9 @@ +import { IsString } from 'class-validator'; + +export class LoginPhoneCodeDto { + @IsString() + phone!: string; + + @IsString() + code!: string; +} diff --git a/src/auth/dto/login.dto.ts b/src/auth/dto/login.dto.ts index d793b00..e419628 100644 --- a/src/auth/dto/login.dto.ts +++ b/src/auth/dto/login.dto.ts @@ -1,10 +1,18 @@ -import { IsEmail, IsString, IsOptional } from 'class-validator'; +import { + IsEmail, + IsString, + MaxLength, + MinLength, + IsOptional, +} from 'class-validator'; export class LoginDto { @IsEmail() email!: string; @IsString() + @MinLength(1) + @MaxLength(128) password!: string; @IsOptional() diff --git a/src/auth/dto/qr-poll.dto.ts b/src/auth/dto/qr-poll.dto.ts new file mode 100644 index 0000000..84d4144 --- /dev/null +++ b/src/auth/dto/qr-poll.dto.ts @@ -0,0 +1,6 @@ +import { IsString } from 'class-validator'; + +export class QrPollDto { + @IsString() + sessionId!: string; +} diff --git a/src/auth/dto/send-code.dto.ts b/src/auth/dto/send-code.dto.ts new file mode 100644 index 0000000..7d9da1a --- /dev/null +++ b/src/auth/dto/send-code.dto.ts @@ -0,0 +1,11 @@ +import { IsEmail, IsString } from 'class-validator'; + +export class SendEmailCodeDto { + @IsEmail() + email!: string; +} + +export class SendPhoneCodeDto { + @IsString() + phone!: string; +} diff --git a/src/auth/dto/update-profile.dto.ts b/src/auth/dto/update-profile.dto.ts new file mode 100644 index 0000000..a8827e4 --- /dev/null +++ b/src/auth/dto/update-profile.dto.ts @@ -0,0 +1,15 @@ +import { IsOptional, IsString, IsUrl } from 'class-validator'; + +export class UpdateProfileDto { + @IsOptional() + @IsString() + displayName?: string; + + @IsOptional() + @IsUrl() + avatarUrl?: string; + + @IsOptional() + @IsString() + phone?: string; +} diff --git a/src/main.ts b/src/main.ts index 9fd291e..9b1d183 100644 --- a/src/main.ts +++ b/src/main.ts @@ -16,7 +16,20 @@ async function bootstrap(): Promise { const logger = app.get(Logger); app.useLogger(logger); - app.use(helmet.default({ contentSecurityPolicy: false })); + app.use( + helmet.default({ + contentSecurityPolicy: { + directives: { + defaultSrc: ["'self'"], + scriptSrc: ["'self'", 'https://cdn.jsdelivr.net'], + styleSrc: ["'self'", 'https://cdn.jsdelivr.net', "'unsafe-inline'"], + imgSrc: ["'self'", 'data:'], + fontSrc: ["'self'", 'https://cdn.jsdelivr.net'], + connectSrc: ["'self'"], + }, + }, + }), + ); app.use(cookieParser()); app.enableCors({ diff --git a/src/oidc/oidc.controller.ts b/src/oidc/oidc.controller.ts index 2af6ba9..4c083bc 100644 --- a/src/oidc/oidc.controller.ts +++ b/src/oidc/oidc.controller.ts @@ -6,13 +6,15 @@ import { Query, HttpCode, HttpStatus, - Headers, Req, Res, + UnauthorizedException, } from '@nestjs/common'; import type { Request, Response } from 'express'; import { Public } from '../common/decorators/public.decorator'; +import { RequestWithUser } from '../common/types'; import { OidcService } from './oidc.service'; +import { TokenDto } from './dto/token.dto'; @Controller() export class OidcController { @@ -28,12 +30,14 @@ export class OidcController { @Query('state') state: string, @Query('code_challenge') codeChallenge: string, @Query('code_challenge_method') codeChallengeMethod: string, + @Req() req: Request, @Res() res: Response, ) { if (responseType !== 'code') { return res.status(400).json({ error: 'unsupported_response_type' }); } + const user = (req as RequestWithUser).user; const result = await this.oidcService.authorize( clientId, redirectUri, @@ -41,6 +45,7 @@ export class OidcController { state, codeChallenge, codeChallengeMethod, + user?.sub, ); return res.redirect(result.redirectUrl); @@ -49,15 +54,15 @@ export class OidcController { @Public() @Post('token') @HttpCode(HttpStatus.OK) - async token(@Body() body: Record) { + async token(@Body() dto: TokenDto) { return this.oidcService.token( - body.grant_type, - body.code, - body.refresh_token, - body.client_id, - body.client_secret, - body.redirect_uri, - body.code_verifier, + dto.grant_type, + dto.code, + dto.refresh_token, + dto.client_id, + dto.client_secret, + dto.redirect_uri, + dto.code_verifier, ); } @@ -66,9 +71,7 @@ export class OidcController { async userinfo(@Req() req: Request) { const authHeader = req.headers.authorization; if (!authHeader || !authHeader.startsWith('Bearer ')) { - throw new (await import('@nestjs/common')).UnauthorizedException( - 'Missing token', - ); + throw new UnauthorizedException('Missing token'); } const token = authHeader.slice(7); return this.oidcService.userinfo(token); diff --git a/src/oidc/oidc.service.ts b/src/oidc/oidc.service.ts index dc8fa81..53ca249 100644 --- a/src/oidc/oidc.service.ts +++ b/src/oidc/oidc.service.ts @@ -30,6 +30,7 @@ export class OidcService { state?: string, codeChallenge?: string, codeChallengeMethod?: string, + userId?: string, ) { const client = await this.prisma.client.findUnique({ where: { clientId }, @@ -43,24 +44,35 @@ export class OidcService { throw new BadRequestException('Invalid redirect_uri'); } + if (!client.isConfidential && !codeChallenge) { + throw new BadRequestException('Public clients must use PKCE'); + } + const authorizationCode = randomBytes(32).toString('hex'); const codeHash = createHash('sha256') .update(authorizationCode) .digest('hex'); + await this.prisma.authorizationCode.create({ + data: { + codeHash, + clientId, + userId: userId ?? null, + redirectUri, + codeChallenge: codeChallenge ?? null, + codeChallengeMethod: codeChallengeMethod ?? null, + scope, + expiresAt: new Date(Date.now() + 10 * 60 * 1000), + }, + }); + const redirectUrl = new URL(redirectUri); redirectUrl.searchParams.set('code', authorizationCode); - redirectUrl.searchParams.set('state', state ?? ''); - - if (codeChallenge && codeChallengeMethod) { - redirectUrl.searchParams.set('code_challenge', codeChallenge); - redirectUrl.searchParams.set( - 'code_challenge_method', - codeChallengeMethod, - ); + if (state) { + redirectUrl.searchParams.set('state', state); } - return { redirectUrl: redirectUrl.toString(), codeHash }; + return { redirectUrl: redirectUrl.toString() }; } async token( @@ -147,23 +159,68 @@ export class OidcService { } } - if (redirectUri && !client.redirectUris.includes(redirectUri)) { - throw new BadRequestException('Invalid redirect_uri'); + const codeHash = createHash('sha256').update(code).digest('hex'); + + const storedCode = await this.prisma.authorizationCode.findUnique({ + where: { codeHash }, + }); + + if (!storedCode || storedCode.usedAt || storedCode.expiresAt < new Date()) { + if (storedCode && !storedCode.usedAt) { + await this.prisma.authorizationCode.update({ + where: { id: storedCode.id }, + data: { usedAt: new Date() }, + }); + } + throw new BadRequestException('Invalid or expired authorization code'); } - if (codeVerifier) { - void createHash('sha256').update(codeVerifier).digest('base64url'); + if (storedCode.clientId !== clientId) { + throw new BadRequestException('Code was issued for a different client'); + } + + if (redirectUri && storedCode.redirectUri !== redirectUri) { + throw new BadRequestException('Redirect URI mismatch'); + } + + if (storedCode.codeChallenge && !codeVerifier) { + throw new BadRequestException('PKCE code_verifier required'); + } + + if (codeVerifier && storedCode.codeChallenge) { + const method = storedCode.codeChallengeMethod ?? 'S256'; + let verifierChallenge: string; + if (method === 'S256') { + verifierChallenge = createHash('sha256') + .update(codeVerifier) + .digest('base64url'); + } else { + verifierChallenge = codeVerifier; + } + + if (verifierChallenge !== storedCode.codeChallenge) { + throw new UnauthorizedException('PKCE verification failed'); + } + } + + await this.prisma.authorizationCode.update({ + where: { id: storedCode.id }, + data: { usedAt: new Date() }, + }); + + const userId = storedCode.userId; + if (!userId) { + throw new UnauthorizedException('No user associated with this code'); } const refreshToken = randomBytes(64).toString('hex'); - const options = { + const options: JwtSignOptions = { secret: this.configService.get('app.jwt.accessSecret'), - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - expiresIn: this.configService.get('app.jwt.accessExpiresIn'), - } satisfies JwtSignOptions; + expiresIn: this.configService.get('app.jwt.accessExpiresIn'), + }; const accessToken = await this.jwtService.signAsync( - { sub: clientId, client_id: clientId }, + { sub: userId, client_id: clientId }, options, ); @@ -187,11 +244,10 @@ export class OidcService { throw new UnauthorizedException('Invalid client credentials'); } - const options = { + const options: JwtSignOptions = { secret: this.configService.get('app.jwt.accessSecret'), - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - expiresIn: this.configService.get('app.jwt.accessExpiresIn'), - } satisfies JwtSignOptions; + expiresIn: this.configService.get('app.jwt.accessExpiresIn'), + }; const accessToken = await this.jwtService.signAsync( { sub: clientId, client_id: clientId }, options, diff --git a/src/qr-auth/qr-auth.service.ts b/src/qr-auth/qr-auth.service.ts index 85deddb..694e980 100644 --- a/src/qr-auth/qr-auth.service.ts +++ b/src/qr-auth/qr-auth.service.ts @@ -1,7 +1,7 @@ import { Injectable, UnauthorizedException } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { ConfigService } from '@nestjs/config'; -import { v4 as uuid } from 'uuid'; +import { randomUUID } from 'node:crypto'; import * as qrcode from 'qrcode'; import { PinoLogger } from 'nestjs-pino'; import { PrismaService } from '../prisma/prisma.service'; @@ -25,8 +25,13 @@ export class QrAuthService { qrDataUrl: string; qrToken: string; }> { - const qrSessionId = uuid(); - const qrToken = uuid(); + const qrSessionId = randomUUID(); + const qrToken = randomUUID(); + + await this.prisma.qrSession.updateMany({ + where: { status: 'PENDING', expiresAt: { gt: new Date() } }, + data: { status: 'EXPIRED' }, + }); const qrSession = await this.prisma.qrSession.create({ data: { @@ -113,30 +118,50 @@ export class QrAuthService { } if (session.status === 'CONFIRMED' && session.userId) { - const user = await this.prisma.user.findUnique({ - where: { id: session.userId }, - select: { id: true, email: true, role: true }, - }); - if (!user) return { status: 'ERROR' }; + const result = await this.prisma.$transaction(async (tx) => { + const s = await tx.qrSession.findUnique({ + where: { id: session.id }, + }); + if (!s || s.status !== 'CONFIRMED') { + return { status: 'PENDING' as const }; + } - const accessToken = await this.jwtService.signAsync( - { - sub: user.id, + const user = await tx.user.findUnique({ + where: { id: s.userId! }, + select: { id: true, email: true, role: true }, + }); + if (!user) return { status: 'ERROR' as const }; + + await tx.qrSession.update({ + where: { id: s.id }, + data: { status: 'EXPIRED' }, + }); + + return { + status: 'CONFIRMED' as const, + userId: user.id, email: user.email, role: user.role, - } satisfies JwtPayload, - { - secret: this.configService.get('app.jwt.accessSecret'), - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - expiresIn: this.configService.get('app.jwt.accessExpiresIn'), - }, - ); - - await this.prisma.qrSession.update({ - where: { id: session.id }, - data: { status: 'EXPIRED' }, + }; }); + if (result.status !== 'CONFIRMED') { + return { status: result.status }; + } + + const options: import('@nestjs/jwt').JwtSignOptions = { + secret: this.configService.get('app.jwt.accessSecret'), + expiresIn: this.configService.get('app.jwt.accessExpiresIn'), + }; + const accessToken = await this.jwtService.signAsync( + { + sub: result.userId, + email: result.email, + role: result.role, + } satisfies JwtPayload, + options, + ); + return { accessToken, status: 'CONFIRMED' }; } diff --git a/src/verification/verification.service.ts b/src/verification/verification.service.ts index f17c318..1fff19e 100644 --- a/src/verification/verification.service.ts +++ b/src/verification/verification.service.ts @@ -19,6 +19,11 @@ export class VerificationService { } async sendEmailCode(email: string): Promise { + await this.prisma.verificationCode.updateMany({ + where: { target: email, usedAt: null, expiresAt: { gt: new Date() } }, + data: { usedAt: new Date() }, + }); + const code = this.generateCode(); await this.prisma.verificationCode.create({ data: { @@ -30,10 +35,18 @@ export class VerificationService { }, }); await this.mailService.sendCode(email, code, 'AUTH'); - this.logger.info({ email }, 'Email verification code sent'); + this.logger.info( + { target: email, channel: 'email' }, + 'Verification code sent', + ); } async sendPhoneCode(phone: string): Promise { + await this.prisma.verificationCode.updateMany({ + where: { target: phone, usedAt: null, expiresAt: { gt: new Date() } }, + data: { usedAt: new Date() }, + }); + const code = this.generateCode(); await this.prisma.verificationCode.create({ data: { @@ -44,7 +57,10 @@ export class VerificationService { expiresAt: new Date(Date.now() + 5 * 60 * 1000), }, }); - this.logger.info({ phone }, 'SMS verification code would be sent (mock)'); + this.logger.info( + { target: phone, channel: 'sms' }, + 'Verification code would be sent (mock)', + ); } async verifyCode(target: string, code: string): Promise { @@ -59,6 +75,10 @@ export class VerificationService { }); if (!record) { + await this.prisma.verificationCode.updateMany({ + where: { target, usedAt: null, expiresAt: { gt: new Date() } }, + data: { usedAt: new Date() }, + }); throw new BadRequestException('Invalid or expired code'); }