third commit

This commit is contained in:
Дмитрий Мамедов
2026-06-10 17:01:10 +03:00
parent d393cbe1db
commit 00919968f2
21 changed files with 721 additions and 274 deletions

View File

@@ -42,38 +42,36 @@ Self-hosted SSO / OIDC-сервис — аналог Keycloak / Яндекс ID.
- `/revoke` - `/revoke`
- PKCE support - 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 ```bash
docker compose up -d docker compose up -d
```
### 2. База данных
```bash
npx prisma db push npx prisma db push
```
### 3. Тестовые данные
```bash
npx ts-node prisma/seed.ts npx ts-node prisma/seed.ts
```
Создаёт:
- **Admin:** `admin@sso.local` / `admin123!`
- **OAuth Client:** `test-client` (секрет в консоли)
### 4. Запуск
```bash
npm run start:dev npm run start:dev
``` ```
Сервис: `http://localhost:3000`
## API Endpoints ## API Endpoints
| Method | Path | Auth | Description | | Method | Path | Auth | Description |

View File

@@ -1,9 +1,10 @@
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail set -euo pipefail
REPO="lendry/sso-mvk" REPO="ssomvk/sso-service"
BRANCH="main" BRANCH="main"
INSTALL_DIR="${INSTALL_DIR:-$HOME/sso-service}" 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' 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"; } 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}"; } step() { echo -e "\n${CYAN}━━━ $1 ━━━${NC}"; }
echo -e "${CYAN}" echo -e "${CYAN}"
echo " ╔═══════════════════════════════════════╗" echo " ╔══════════════════════════════════════════════╗"
echo " ║ SSO Service Installer ║" echo " ║ SSO Service — Auto Installer ║"
echo " ╚═══════════════════════════════════════╝" echo " ║ https://github.com/${REPO}"
echo " ╚══════════════════════════════════════════════╝"
echo -e "${NC}" echo -e "${NC}"
# ─── Prerequisites ────────────────────────────────────────────── # ─── Проверка root ──────────────────────────────────────────────
step "1/7 Checking prerequisites" if [ "$(id -u)" -eq 0 ]; then
command -v node >/dev/null 2>&1 || error "Node.js required → https://nodejs.org" SUDO=""
command -v npm >/dev/null 2>&1 || error "npm required" else
command -v docker compose >/dev/null 2>&1 || error "Docker Compose required" SUDO="sudo"
info "node $(node -v), npm $(npm -v), docker compose found" fi
# ─── Clone / Pull ─────────────────────────────────────────────── # ─── Detecting OS ────────────────────────────────────────────────
step "2/7 Downloading SSO Service" 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 if [ -d "$INSTALL_DIR/.git" ]; then
cd "$INSTALL_DIR" cd "$INSTALL_DIR"
git pull --ff-only 2>/dev/null && info "Updated existing installation" || warn "Could not git pull, continuing" git pull --ff-only 2>/dev/null && info "Updated existing installation" || warn "Could not git pull, continuing"
else else
mkdir -p "$INSTALL_DIR" mkdir -p "$INSTALL_DIR"
if command -v git &>/dev/null; then 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 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" curl -fsSL "https://github.com/$REPO/archive/refs/heads/$BRANCH.tar.gz" | tar xz --strip=1 -C "$INSTALL_DIR"
fi fi
info "Downloaded to $INSTALL_DIR" info "Downloaded to $INSTALL_DIR"
fi fi
cd "$INSTALL_DIR" cd "$INSTALL_DIR"
# ─── .env ──────────────────────────────────────────────────────── # ─── 5. .env ─────────────────────────────────────────────────────
step "3/7 Configuring environment" step "4/8 Configuring environment"
if [ ! -f .env ]; then if [ ! -f .env ]; then
cp .env.example .env cp .env.example .env
sed -i "s/change-me-access-secret-at-least-32-chars/$(openssl rand -hex 32)/" .env # Генерация случайных JWT-секретов (32 байта hex = 64 символа)
sed -i "s/change-me-refresh-secret-at-least-32-chars/$(openssl rand -hex 32)/" .env JWT_ACCESS=$(openssl rand -hex 32 2>/dev/null || node -e "process.stdout.write(require('crypto').randomBytes(32).toString('hex'))")
info "Created .env with random secrets" 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 else
info ".env already exists, keeping it" info ".env already exists, keeping it"
fi fi
# shellcheck source=/dev/null
set -a; source .env; set +a set -a; source .env; set +a
# ─── Docker ────────────────────────────────────────────────────── # ─── 6. Docker infrastructure ────────────────────────────────────
step "4/7 Starting infrastructure (PostgreSQL, Redis, RabbitMQ)" step "5/8 Starting infrastructure (PostgreSQL, Redis, RabbitMQ)"
docker compose up -d 2>&1 $SUDO docker compose up -d 2>&1
info "Waiting for PostgreSQL..." info "Waiting for PostgreSQL..."
for i in $(seq 1 30); do 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" info "PostgreSQL is ready"
break break
fi 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 sleep 2
done done
# ─── Dependencies & Prisma ────────────────────────────────────── info "Waiting for Redis..."
step "5/7 Installing dependencies and generating Prisma client" 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 npm ci --no-audit --no-fund 2>&1 | tail -1
info "npm dependencies installed"
npx prisma generate 2>&1 | tail -1 npx prisma generate 2>&1 | tail -1
info "Prisma client generated"
export DATABASE_URL export DATABASE_URL
npx prisma db push --skip-generate 2>&1 npx prisma db push --skip-generate 2>&1
info "Database schema applied" 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" info "Seed data created"
# ─── Build ────────────────────────────────────────────────────── # ─── 9. Build & Start ────────────────────────────────────────────
echo "" step "8/8 Building and starting SSO Service"
npm run build 2>&1 | tail -3 npm run build 2>&1 | tail -3
info "Application built" info "Application built"
# ─── Start ──────────────────────────────────────────────────────
step "6/7 Starting SSO Service"
if command -v pm2 &>/dev/null; then if command -v pm2 &>/dev/null; then
pm2 delete sso-service 2>/dev/null || true pm2 delete sso-service 2>/dev/null || true
pm2 start npm --name "sso-service" -- run start:prod pm2 start npm --name "sso-service" -- run start:prod
pm2 save pm2 save 2>/dev/null || true
info "Started with PM2" info "Started with PM2"
else else
PORT="${PORT:-3000}" nohup node dist/main > sso.log 2>&1 & PORT="${PORT:-3000}" nohup node dist/main > "$INSTALL_DIR/sso.log" 2>&1 &
echo $! > .sso.pid echo $! > "$INSTALL_DIR/.sso.pid"
info "Started in background (PID $(cat .sso.pid)), logs: sso.log" info "Started in background (PID $(cat "$INSTALL_DIR/.sso.pid")) logs: $INSTALL_DIR/sso.log"
fi fi
sleep 3 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}" info "Service is running on http://localhost:${PORT:-3000}"
else else
warn "Service may still be starting... check sso.log" warn "Service may still be starting... check: $INSTALL_DIR/sso.log"
fi fi
# ─── Done ──────────────────────────────────────────────────────── # ─── Done ────────────────────────────────────────────────────────
step "7/7 Installation complete!"
echo "" echo ""
echo -e " ${GREEN}Web panel:${NC} http://localhost:${PORT:-3000}" echo -e "${GREEN}════════════════════════════════════════════════${NC}"
echo -e " ${GREEN}Admin login:${NC} admin@sso.local / admin123!" echo -e "${GREEN} SSO Service installed and running!${NC}"
echo -e " ${GREEN}Metrics:${NC} http://localhost:${PORT:-3000}/metrics" echo -e "${GREEN}════════════════════════════════════════════════${NC}"
echo -e " ${GREEN}RabbitMQ UI:${NC} http://localhost:15672 (sso / sso_secret)"
echo "" echo ""
echo -e " ${YELLOW}To stop:${NC} kill \$(cat $INSTALL_DIR/.sso.pid) (or pm2 stop sso-service)" echo -e " ${CYAN}Web panel:${NC} http://localhost:${PORT:-3000}"
echo -e " ${YELLOW}To update:${NC} cd $INSTALL_DIR && git pull && npm ci && npm run build && npm run start:prod" 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 "" echo ""

View File

@@ -37,6 +37,7 @@ model User {
auditLogs AuditLog[] auditLogs AuditLog[]
verificationCodes VerificationCode[] verificationCodes VerificationCode[]
qrSessions QrSession[] qrSessions QrSession[]
authorizationCodes AuthorizationCode[]
@@map("users") @@map("users")
} }
@@ -124,6 +125,24 @@ model QrSession {
@@map("qr_sessions") @@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 { model AuditLog {
id String @id @default(uuid()) @db.Uuid id String @id @default(uuid()) @db.Uuid
userId String? @map("user_id") @db.Uuid userId String? @map("user_id") @db.Uuid

View File

@@ -7,18 +7,20 @@ import {
Body, Body,
Param, Param,
Query, Query,
UseGuards, Req,
ParseUUIDPipe,
ParseIntPipe,
DefaultValuePipe,
} from '@nestjs/common'; } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport'; import type { Request } from 'express';
import { Role } from '@prisma/client'; import { Role } from '@prisma/client';
import { Roles } from '../common/decorators/roles.decorator'; 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 { AdminService } from './admin.service';
import { CreateUserDto, UpdateUserDto } from './dto/create-user.dto'; import { CreateUserDto, UpdateUserDto } from './dto/create-user.dto';
import { CreateClientDto, UpdateClientDto } from './dto/create-client.dto'; import { CreateClientDto, UpdateClientDto } from './dto/create-client.dto';
@Controller('admin') @Controller('admin')
@UseGuards(AuthGuard('jwt'), RolesGuard)
@Roles(Role.ADMIN) @Roles(Role.ADMIN)
export class AdminController { export class AdminController {
constructor(private readonly adminService: AdminService) {} constructor(private readonly adminService: AdminService) {}
@@ -30,49 +32,73 @@ export class AdminController {
@Get('users') @Get('users')
async listUsers( async listUsers(
@Query('page') page?: string, @Query(
@Query('limit') limit?: string, '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( return this.adminService.listUsers(page ?? 1, limit ?? 20);
page ? parseInt(page, 10) : 1,
limit ? parseInt(limit, 10) : 20,
);
} }
@Get('users/:id') @Get('users/:id')
async getUser(@Param('id') id: string) { async getUser(@Param('id', ParseUUIDPipe) id: string) {
return this.adminService.getUser(id); return this.adminService.getUser(id);
} }
@Post('users') @Post('users')
async createUser(@Body() dto: CreateUserDto) { async createUser(@Body() dto: CreateUserDto, @Req() req: Request) {
return this.adminService.createUser(dto); const actorId = (req as RequestWithUser).user?.sub;
return this.adminService.createUser(dto, actorId);
} }
@Put('users/:id') @Put('users/:id')
async updateUser(@Param('id') id: string, @Body() dto: UpdateUserDto) { async updateUser(
return this.adminService.updateUser(id, dto); @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') @Delete('users/:id')
async deleteUser(@Param('id') id: string) { async deleteUser(
await this.adminService.deleteUser(id); @Param('id', ParseUUIDPipe) id: string,
@Req() req: Request,
) {
const actorId = (req as RequestWithUser).user?.sub;
await this.adminService.deleteUser(id, actorId);
return { deleted: true }; return { deleted: true };
} }
@Get('clients') @Get('clients')
async listClients( async listClients(
@Query('page') page?: string, @Query(
@Query('limit') limit?: string, '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( return this.adminService.listClients(page ?? 1, limit ?? 20);
page ? parseInt(page, 10) : 1,
limit ? parseInt(limit, 10) : 20,
);
} }
@Get('clients/:id') @Get('clients/:id')
async getClient(@Param('id') id: string) { async getClient(@Param('id', ParseUUIDPipe) id: string) {
return this.adminService.getClient(id); return this.adminService.getClient(id);
} }
@@ -82,12 +108,15 @@ export class AdminController {
} }
@Put('clients/:id') @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); return this.adminService.updateClient(id, dto);
} }
@Delete('clients/:id') @Delete('clients/:id')
async deleteClient(@Param('id') id: string) { async deleteClient(@Param('id', ParseUUIDPipe) id: string) {
await this.adminService.deleteClient(id); await this.adminService.deleteClient(id);
return { deleted: true }; return { deleted: true };
} }

View File

@@ -1,7 +1,12 @@
import { Injectable, NotFoundException } from '@nestjs/common'; import {
Injectable,
NotFoundException,
ForbiddenException,
} from '@nestjs/common';
import * as argon2 from 'argon2'; import * as argon2 from 'argon2';
import { randomBytes } from 'node:crypto'; import { randomBytes } from 'node:crypto';
import { PinoLogger } from 'nestjs-pino'; import { PinoLogger } from 'nestjs-pino';
import { Role } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { CreateUserDto, UpdateUserDto } from './dto/create-user.dto'; import { CreateUserDto, UpdateUserDto } from './dto/create-user.dto';
import { CreateClientDto, UpdateClientDto } from './dto/create-client.dto'; import { CreateClientDto, UpdateClientDto } from './dto/create-client.dto';
@@ -61,21 +66,35 @@ export class AdminService {
return user; return user;
} }
async createUser(dto: CreateUserDto) { async createUser(dto: CreateUserDto, actorId?: string) {
const passwordHash = await argon2.hash(dto.password); const passwordHash = await argon2.hash(dto.password);
return this.prisma.user.create({ const role = dto.role === Role.ADMIN ? Role.ADMIN : Role.USER;
return this.prisma.$transaction(async (tx) => {
const user = await tx.user.create({
data: { data: {
email: dto.email, email: dto.email,
passwordHash, passwordHash,
displayName: dto.displayName, displayName: dto.displayName,
phone: dto.phone, phone: dto.phone,
role: dto.role ?? 'USER', role,
}, },
select: { id: true, email: true, displayName: true, role: true }, 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 } }); const user = await this.prisma.user.findUnique({ where: { id } });
if (!user) throw new NotFoundException('User not found'); if (!user) throw new NotFoundException('User not found');
@@ -83,11 +102,20 @@ export class AdminService {
if (dto.email) data.email = dto.email; if (dto.email) data.email = dto.email;
if (dto.displayName) data.displayName = dto.displayName; if (dto.displayName) data.displayName = dto.displayName;
if (dto.phone) data.phone = dto.phone; if (dto.phone) data.phone = dto.phone;
if (dto.role) data.role = dto.role;
if (dto.isActive !== undefined) data.isActive = dto.isActive; if (dto.isActive !== undefined) data.isActive = dto.isActive;
if (dto.password) data.passwordHash = await argon2.hash(dto.password); 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({ return this.prisma.$transaction(async (tx) => {
const updated = await tx.user.update({
where: { id }, where: { id },
data, data,
select: { select: {
@@ -98,12 +126,33 @@ export class AdminService {
isActive: 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 } }); const user = await this.prisma.user.findUnique({ where: { id } });
if (!user) throw new NotFoundException('User not found'); 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) { async listClients(page = 1, limit = 20) {
@@ -159,7 +208,6 @@ export class AdminService {
const data: Record<string, unknown> = {}; const data: Record<string, unknown> = {};
if (dto.name) data.name = dto.name; if (dto.name) data.name = dto.name;
if (dto.description !== undefined) data.description = dto.description; if (dto.description !== undefined) data.description = dto.description;
if (dto.clientSecret) data.clientSecret = dto.clientSecret;
if (dto.redirectUris) data.redirectUris = dto.redirectUris; if (dto.redirectUris) data.redirectUris = dto.redirectUris;
if (dto.grants) data.grants = dto.grants; if (dto.grants) data.grants = dto.grants;
if (dto.isConfidential !== undefined) if (dto.isConfidential !== undefined)

View File

@@ -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 { export class CreateClientDto {
@IsString() @IsString()
@@ -16,9 +30,13 @@ export class CreateClientDto {
clientSecret?: string; clientSecret?: string;
@IsArray() @IsArray()
@ArrayMinSize(1)
@IsUrl({ require_tld: false }, { each: true })
redirectUris!: string[]; redirectUris!: string[];
@IsArray() @IsArray()
@ArrayMinSize(1)
@IsEnum(GrantType, { each: true })
grants!: string[]; grants!: string[];
@IsOptional() @IsOptional()
@@ -45,10 +63,12 @@ export class UpdateClientDto {
@IsOptional() @IsOptional()
@IsArray() @IsArray()
@IsUrl({ require_tld: false }, { each: true })
redirectUris?: string[]; redirectUris?: string[];
@IsOptional() @IsOptional()
@IsArray() @IsArray()
@IsEnum(GrantType, { each: true })
grants?: string[]; grants?: string[];
@IsOptional() @IsOptional()

View File

@@ -4,6 +4,7 @@ import {
MinLength, MinLength,
IsOptional, IsOptional,
IsEnum, IsEnum,
IsBoolean,
} from 'class-validator'; } from 'class-validator';
import { Role } from '@prisma/client'; import { Role } from '@prisma/client';
@@ -51,5 +52,6 @@ export class UpdateUserDto {
role?: Role; role?: Role;
@IsOptional() @IsOptional()
@IsBoolean()
isActive?: boolean; isActive?: boolean;
} }

View File

@@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config'; import { ConfigModule } from '@nestjs/config';
import { ThrottlerModule } from '@nestjs/throttler'; import { ThrottlerModule } from '@nestjs/throttler';
import { APP_FILTER, APP_GUARD } from '@nestjs/core'; import { APP_FILTER, APP_GUARD } from '@nestjs/core';
import { ThrottlerGuard } from '@nestjs/throttler';
import { LoggerModule } from 'nestjs-pino'; import { LoggerModule } from 'nestjs-pino';
import { PrismaModule } from './prisma/prisma.module'; import { PrismaModule } from './prisma/prisma.module';
import { AuthModule } from './auth/auth.module'; import { AuthModule } from './auth/auth.module';
@@ -75,6 +76,7 @@ import appConfig from './config/configuration';
], ],
providers: [ providers: [
{ provide: APP_FILTER, useClass: AllExceptionsFilter }, { provide: APP_FILTER, useClass: AllExceptionsFilter },
{ provide: APP_GUARD, useClass: ThrottlerGuard },
{ provide: APP_GUARD, useClass: JwtAuthGuard }, { provide: APP_GUARD, useClass: JwtAuthGuard },
{ provide: APP_GUARD, useClass: RolesGuard }, { provide: APP_GUARD, useClass: RolesGuard },
], ],

View File

@@ -6,14 +6,19 @@ import {
HttpCode, HttpCode,
HttpStatus, HttpStatus,
Req, Req,
UseGuards,
} from '@nestjs/common'; } from '@nestjs/common';
import { Throttle } from '@nestjs/throttler'; import { Throttle } from '@nestjs/throttler';
import { AuthGuard } from '@nestjs/passport';
import type { Request } from 'express'; import type { Request } from 'express';
import { Public } from '../common/decorators/public.decorator'; import { Public } from '../common/decorators/public.decorator';
import { RequestWithUser } from '../common/types'; import { RequestWithUser } from '../common/types';
import { AuthService } from './auth.service'; 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') @Controller('auth')
export class AuthController { export class AuthController {
@@ -21,34 +26,25 @@ export class AuthController {
@Public() @Public()
@Post('register') @Post('register')
async register( async register(@Body() dto: RegisterDto, @Req() req: Request) {
@Body() body: { email: string; password: string; displayName?: string }, return this.authService.register(dto, req.ip, req.headers['user-agent']);
@Req() req: Request,
) {
return this.authService.register(body, req.ip, req.headers['user-agent']);
} }
@Public() @Public()
@Throttle({ default: { ttl: 60000, limit: 5 } }) @Throttle({ default: { ttl: 60000, limit: 5 } })
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@Post('login') @Post('login')
async login( async login(@Body() dto: LoginDto, @Req() req: Request) {
@Body() body: { email: string; password: string }, return this.authService.login(dto, req.ip, req.headers['user-agent']);
@Req() req: Request,
) {
return this.authService.login(body, req.ip, req.headers['user-agent']);
} }
@Public() @Public()
@Post('login/email-code') @Post('login/email-code')
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
async loginByEmailCode( async loginByEmailCode(@Body() dto: LoginEmailCodeDto, @Req() req: Request) {
@Body() body: { email: string; code: string },
@Req() req: Request,
) {
return this.authService.loginByEmailCode( return this.authService.loginByEmailCode(
body.email, dto.email,
body.code, dto.code,
req.ip, req.ip,
req.headers['user-agent'], req.headers['user-agent'],
); );
@@ -57,13 +53,10 @@ export class AuthController {
@Public() @Public()
@Post('login/phone-code') @Post('login/phone-code')
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
async loginByPhoneCode( async loginByPhoneCode(@Body() dto: LoginPhoneCodeDto, @Req() req: Request) {
@Body() body: { phone: string; code: string },
@Req() req: Request,
) {
return this.authService.loginByPhoneCode( return this.authService.loginByPhoneCode(
body.phone, dto.phone,
body.code, dto.code,
req.ip, req.ip,
req.headers['user-agent'], req.headers['user-agent'],
); );
@@ -72,16 +65,16 @@ export class AuthController {
@Public() @Public()
@Post('send-email-code') @Post('send-email-code')
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
async sendEmailCode(@Body() body: { email: string }) { async sendEmailCode(@Body() dto: SendEmailCodeDto) {
await this.authService.sendEmailCode(body.email); await this.authService.sendEmailCode(dto.email);
return { sent: true }; return { sent: true };
} }
@Public() @Public()
@Post('send-phone-code') @Post('send-phone-code')
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
async sendPhoneCode(@Body() body: { phone: string }) { async sendPhoneCode(@Body() dto: SendPhoneCodeDto) {
await this.authService.sendPhoneCode(body.phone); await this.authService.sendPhoneCode(dto.phone);
return { sent: true }; return { sent: true };
} }
@@ -95,8 +88,8 @@ export class AuthController {
@Public() @Public()
@Post('qr/poll') @Post('qr/poll')
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
async pollQr(@Body() body: { sessionId: string }) { async pollQr(@Body() dto: QrPollDto) {
return this.authService.pollQrSession(body.sessionId); return this.authService.pollQrSession(dto.sessionId);
} }
@Public() @Public()
@@ -120,21 +113,16 @@ export class AuthController {
await this.authService.logout(refreshToken); await this.authService.logout(refreshToken);
} }
@UseGuards(AuthGuard('jwt'))
@Get('profile') @Get('profile')
async profile(@Req() req: Request) { async profile(@Req() req: Request) {
const user = (req as RequestWithUser).user; const user = (req as RequestWithUser).user;
return this.authService.getProfile(user.sub); return this.authService.getProfile(user.sub);
} }
@UseGuards(AuthGuard('jwt'))
@Post('profile/update') @Post('profile/update')
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
async updateProfile( async updateProfile(@Body() dto: UpdateProfileDto, @Req() req: Request) {
@Body() body: { displayName?: string; avatarUrl?: string; phone?: string },
@Req() req: Request,
) {
const user = (req as RequestWithUser).user; const user = (req as RequestWithUser).user;
return this.authService.updateProfile(user.sub, body); return this.authService.updateProfile(user.sub, dto);
} }
} }

View File

@@ -40,7 +40,8 @@ export class AuthService {
const passwordHash = await argon2.hash(dto.password); const passwordHash = await argon2.hash(dto.password);
const user = await this.prisma.user.create({ const user = await this.prisma.$transaction(async (tx) => {
const u = await tx.user.create({
data: { data: {
email: dto.email, email: dto.email,
passwordHash, passwordHash,
@@ -48,15 +49,18 @@ export class AuthService {
}, },
}); });
await this.prisma.auditLog.create({ await tx.auditLog.create({
data: { data: {
userId: user.id, userId: u.id,
action: 'USER_REGISTERED', action: 'USER_REGISTERED',
ip, ip,
userAgent, userAgent,
}, },
}); });
return u;
});
this.eventsService.emit('auth.user.registered', { this.eventsService.emit('auth.user.registered', {
userId: user.id, userId: user.id,
email: user.email, email: user.email,
@@ -199,17 +203,50 @@ export class AuthService {
throw new UnauthorizedException('Session expired'); throw new UnauthorizedException('Session expired');
} }
await this.prisma.refreshToken.update({ const result = await this.prisma.$transaction(async (tx) => {
await tx.refreshToken.update({
where: { id: stored.id }, where: { id: stored.id },
data: { revoked: true }, data: { revoked: true },
}); });
return this.createSession( const now = new Date();
stored.session.userId, const refreshExpiresIn = this.configService.get<string>(
stored.session.clientId ?? undefined, 'app.jwt.refreshExpiresIn',
ip ?? stored.session.ip ?? undefined, )!;
userAgent ?? stored.session.userAgent ?? undefined, 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 };
});
const accessToken = await this.generateAccessToken(stored.session.userId);
return {
accessToken,
refreshToken: result.refreshToken,
sessionId: result.sessionId,
};
} }
async logout(refreshToken: string) { async logout(refreshToken: string) {
@@ -220,17 +257,19 @@ export class AuthService {
}); });
if (stored) { if (stored) {
await this.prisma.refreshToken.update({ await this.prisma.$transaction(async (tx) => {
await tx.refreshToken.update({
where: { id: stored.id }, where: { id: stored.id },
data: { revoked: true }, data: { revoked: true },
}); });
if (stored.session) { if (stored.session) {
await this.prisma.session.update({ await tx.session.update({
where: { id: stored.session.id }, where: { id: stored.session.id },
data: { isActive: false }, data: { isActive: false },
}); });
} }
});
} }
} }
@@ -275,30 +314,29 @@ export class AuthService {
ip?: string, ip?: string,
userAgent?: 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 accessToken = await this.generateAccessToken(userId);
const refreshToken = this.generateRefreshToken(); const refreshToken = this.generateRefreshToken();
const refreshTokenHash = this.hashToken(refreshToken); const refreshTokenHash = this.hashToken(refreshToken);
const refreshExpiresIn = this.configService.get<string>( const refreshExpiresIn = this.configService.get<string>(
'app.jwt.refreshExpiresIn', 'app.jwt.refreshExpiresIn',
)!; )!;
const refreshMs = this.parseDuration(refreshExpiresIn); const refreshMs = this.parseDuration(refreshExpiresIn);
await this.prisma.refreshToken.create({ 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: { data: {
tokenHash: refreshTokenHash, tokenHash: refreshTokenHash,
sessionId: session.id, sessionId: session.id,
@@ -306,7 +344,10 @@ export class AuthService {
}, },
}); });
return { accessToken, refreshToken, sessionId: session.id }; return { id: session.id };
});
return { accessToken, refreshToken, sessionId };
} }
private async generateAccessToken(userId: string): Promise<string> { private async generateAccessToken(userId: string): Promise<string> {
@@ -317,8 +358,7 @@ export class AuthService {
const options: JwtSignOptions = { const options: JwtSignOptions = {
secret: this.configService.get<string>('app.jwt.accessSecret'), secret: this.configService.get<string>('app.jwt.accessSecret'),
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment expiresIn: this.configService.get('app.jwt.accessExpiresIn'),
expiresIn: this.configService.get<any>('app.jwt.accessExpiresIn'),
}; };
return this.jwtService.signAsync( return this.jwtService.signAsync(
{ {

View File

@@ -0,0 +1,9 @@
import { IsEmail, IsString } from 'class-validator';
export class LoginEmailCodeDto {
@IsEmail()
email!: string;
@IsString()
code!: string;
}

View File

@@ -0,0 +1,9 @@
import { IsString } from 'class-validator';
export class LoginPhoneCodeDto {
@IsString()
phone!: string;
@IsString()
code!: string;
}

View File

@@ -1,10 +1,18 @@
import { IsEmail, IsString, IsOptional } from 'class-validator'; import {
IsEmail,
IsString,
MaxLength,
MinLength,
IsOptional,
} from 'class-validator';
export class LoginDto { export class LoginDto {
@IsEmail() @IsEmail()
email!: string; email!: string;
@IsString() @IsString()
@MinLength(1)
@MaxLength(128)
password!: string; password!: string;
@IsOptional() @IsOptional()

View File

@@ -0,0 +1,6 @@
import { IsString } from 'class-validator';
export class QrPollDto {
@IsString()
sessionId!: string;
}

View File

@@ -0,0 +1,11 @@
import { IsEmail, IsString } from 'class-validator';
export class SendEmailCodeDto {
@IsEmail()
email!: string;
}
export class SendPhoneCodeDto {
@IsString()
phone!: string;
}

View File

@@ -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;
}

View File

@@ -16,7 +16,20 @@ async function bootstrap(): Promise<void> {
const logger = app.get(Logger); const logger = app.get(Logger);
app.useLogger(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.use(cookieParser());
app.enableCors({ app.enableCors({

View File

@@ -6,13 +6,15 @@ import {
Query, Query,
HttpCode, HttpCode,
HttpStatus, HttpStatus,
Headers,
Req, Req,
Res, Res,
UnauthorizedException,
} from '@nestjs/common'; } from '@nestjs/common';
import type { Request, Response } from 'express'; import type { Request, Response } from 'express';
import { Public } from '../common/decorators/public.decorator'; import { Public } from '../common/decorators/public.decorator';
import { RequestWithUser } from '../common/types';
import { OidcService } from './oidc.service'; import { OidcService } from './oidc.service';
import { TokenDto } from './dto/token.dto';
@Controller() @Controller()
export class OidcController { export class OidcController {
@@ -28,12 +30,14 @@ export class OidcController {
@Query('state') state: string, @Query('state') state: string,
@Query('code_challenge') codeChallenge: string, @Query('code_challenge') codeChallenge: string,
@Query('code_challenge_method') codeChallengeMethod: string, @Query('code_challenge_method') codeChallengeMethod: string,
@Req() req: Request,
@Res() res: Response, @Res() res: Response,
) { ) {
if (responseType !== 'code') { if (responseType !== 'code') {
return res.status(400).json({ error: 'unsupported_response_type' }); return res.status(400).json({ error: 'unsupported_response_type' });
} }
const user = (req as RequestWithUser).user;
const result = await this.oidcService.authorize( const result = await this.oidcService.authorize(
clientId, clientId,
redirectUri, redirectUri,
@@ -41,6 +45,7 @@ export class OidcController {
state, state,
codeChallenge, codeChallenge,
codeChallengeMethod, codeChallengeMethod,
user?.sub,
); );
return res.redirect(result.redirectUrl); return res.redirect(result.redirectUrl);
@@ -49,15 +54,15 @@ export class OidcController {
@Public() @Public()
@Post('token') @Post('token')
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
async token(@Body() body: Record<string, string>) { async token(@Body() dto: TokenDto) {
return this.oidcService.token( return this.oidcService.token(
body.grant_type, dto.grant_type,
body.code, dto.code,
body.refresh_token, dto.refresh_token,
body.client_id, dto.client_id,
body.client_secret, dto.client_secret,
body.redirect_uri, dto.redirect_uri,
body.code_verifier, dto.code_verifier,
); );
} }
@@ -66,9 +71,7 @@ export class OidcController {
async userinfo(@Req() req: Request) { async userinfo(@Req() req: Request) {
const authHeader = req.headers.authorization; const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) { if (!authHeader || !authHeader.startsWith('Bearer ')) {
throw new (await import('@nestjs/common')).UnauthorizedException( throw new UnauthorizedException('Missing token');
'Missing token',
);
} }
const token = authHeader.slice(7); const token = authHeader.slice(7);
return this.oidcService.userinfo(token); return this.oidcService.userinfo(token);

View File

@@ -30,6 +30,7 @@ export class OidcService {
state?: string, state?: string,
codeChallenge?: string, codeChallenge?: string,
codeChallengeMethod?: string, codeChallengeMethod?: string,
userId?: string,
) { ) {
const client = await this.prisma.client.findUnique({ const client = await this.prisma.client.findUnique({
where: { clientId }, where: { clientId },
@@ -43,24 +44,35 @@ export class OidcService {
throw new BadRequestException('Invalid redirect_uri'); 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 authorizationCode = randomBytes(32).toString('hex');
const codeHash = createHash('sha256') const codeHash = createHash('sha256')
.update(authorizationCode) .update(authorizationCode)
.digest('hex'); .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); const redirectUrl = new URL(redirectUri);
redirectUrl.searchParams.set('code', authorizationCode); redirectUrl.searchParams.set('code', authorizationCode);
redirectUrl.searchParams.set('state', state ?? ''); if (state) {
redirectUrl.searchParams.set('state', state);
if (codeChallenge && codeChallengeMethod) {
redirectUrl.searchParams.set('code_challenge', codeChallenge);
redirectUrl.searchParams.set(
'code_challenge_method',
codeChallengeMethod,
);
} }
return { redirectUrl: redirectUrl.toString(), codeHash }; return { redirectUrl: redirectUrl.toString() };
} }
async token( async token(
@@ -147,23 +159,68 @@ export class OidcService {
} }
} }
if (redirectUri && !client.redirectUris.includes(redirectUri)) { const codeHash = createHash('sha256').update(code).digest('hex');
throw new BadRequestException('Invalid redirect_uri');
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) { if (storedCode.clientId !== clientId) {
void createHash('sha256').update(codeVerifier).digest('base64url'); 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 refreshToken = randomBytes(64).toString('hex');
const options = { const options: JwtSignOptions = {
secret: this.configService.get<string>('app.jwt.accessSecret'), secret: this.configService.get<string>('app.jwt.accessSecret'),
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment expiresIn: this.configService.get('app.jwt.accessExpiresIn'),
expiresIn: this.configService.get<any>('app.jwt.accessExpiresIn'), };
} satisfies JwtSignOptions;
const accessToken = await this.jwtService.signAsync( const accessToken = await this.jwtService.signAsync(
{ sub: clientId, client_id: clientId }, { sub: userId, client_id: clientId },
options, options,
); );
@@ -187,11 +244,10 @@ export class OidcService {
throw new UnauthorizedException('Invalid client credentials'); throw new UnauthorizedException('Invalid client credentials');
} }
const options = { const options: JwtSignOptions = {
secret: this.configService.get<string>('app.jwt.accessSecret'), secret: this.configService.get<string>('app.jwt.accessSecret'),
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment expiresIn: this.configService.get('app.jwt.accessExpiresIn'),
expiresIn: this.configService.get<any>('app.jwt.accessExpiresIn'), };
} satisfies JwtSignOptions;
const accessToken = await this.jwtService.signAsync( const accessToken = await this.jwtService.signAsync(
{ sub: clientId, client_id: clientId }, { sub: clientId, client_id: clientId },
options, options,

View File

@@ -1,7 +1,7 @@
import { Injectable, UnauthorizedException } from '@nestjs/common'; import { Injectable, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt'; import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { v4 as uuid } from 'uuid'; import { randomUUID } from 'node:crypto';
import * as qrcode from 'qrcode'; import * as qrcode from 'qrcode';
import { PinoLogger } from 'nestjs-pino'; import { PinoLogger } from 'nestjs-pino';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
@@ -25,8 +25,13 @@ export class QrAuthService {
qrDataUrl: string; qrDataUrl: string;
qrToken: string; qrToken: string;
}> { }> {
const qrSessionId = uuid(); const qrSessionId = randomUUID();
const qrToken = uuid(); 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({ const qrSession = await this.prisma.qrSession.create({
data: { data: {
@@ -113,30 +118,50 @@ export class QrAuthService {
} }
if (session.status === 'CONFIRMED' && session.userId) { if (session.status === 'CONFIRMED' && session.userId) {
const user = await this.prisma.user.findUnique({ const result = await this.prisma.$transaction(async (tx) => {
where: { id: session.userId }, const s = await tx.qrSession.findUnique({
where: { id: session.id },
});
if (!s || s.status !== 'CONFIRMED') {
return { status: 'PENDING' as const };
}
const user = await tx.user.findUnique({
where: { id: s.userId! },
select: { id: true, email: true, role: true }, select: { id: true, email: true, role: true },
}); });
if (!user) return { status: 'ERROR' }; if (!user) return { status: 'ERROR' as const };
const accessToken = await this.jwtService.signAsync( await tx.qrSession.update({
{ where: { id: s.id },
sub: user.id,
email: user.email,
role: user.role,
} satisfies JwtPayload,
{
secret: this.configService.get<string>('app.jwt.accessSecret'),
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
expiresIn: this.configService.get<any>('app.jwt.accessExpiresIn'),
},
);
await this.prisma.qrSession.update({
where: { id: session.id },
data: { status: 'EXPIRED' }, data: { status: 'EXPIRED' },
}); });
return {
status: 'CONFIRMED' as const,
userId: user.id,
email: user.email,
role: user.role,
};
});
if (result.status !== 'CONFIRMED') {
return { status: result.status };
}
const options: import('@nestjs/jwt').JwtSignOptions = {
secret: this.configService.get<string>('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' }; return { accessToken, status: 'CONFIRMED' };
} }

View File

@@ -19,6 +19,11 @@ export class VerificationService {
} }
async sendEmailCode(email: string): Promise<void> { async sendEmailCode(email: string): Promise<void> {
await this.prisma.verificationCode.updateMany({
where: { target: email, usedAt: null, expiresAt: { gt: new Date() } },
data: { usedAt: new Date() },
});
const code = this.generateCode(); const code = this.generateCode();
await this.prisma.verificationCode.create({ await this.prisma.verificationCode.create({
data: { data: {
@@ -30,10 +35,18 @@ export class VerificationService {
}, },
}); });
await this.mailService.sendCode(email, code, 'AUTH'); 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<void> { async sendPhoneCode(phone: string): Promise<void> {
await this.prisma.verificationCode.updateMany({
where: { target: phone, usedAt: null, expiresAt: { gt: new Date() } },
data: { usedAt: new Date() },
});
const code = this.generateCode(); const code = this.generateCode();
await this.prisma.verificationCode.create({ await this.prisma.verificationCode.create({
data: { data: {
@@ -44,7 +57,10 @@ export class VerificationService {
expiresAt: new Date(Date.now() + 5 * 60 * 1000), 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<boolean> { async verifyCode(target: string, code: string): Promise<boolean> {
@@ -59,6 +75,10 @@ export class VerificationService {
}); });
if (!record) { 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'); throw new BadRequestException('Invalid or expired code');
} }