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`
- 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 |

View File

@@ -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 ""

View File

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

View File

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

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 { 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({
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: dto.role ?? 'USER',
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,11 +102,20 @@ 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({
return this.prisma.$transaction(async (tx) => {
const updated = await tx.user.update({
where: { id },
data,
select: {
@@ -98,12 +126,33 @@ export class AdminService {
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<string, unknown> = {};
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)

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 {
@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()

View File

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

View File

@@ -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 },
],

View File

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

View File

@@ -40,7 +40,8 @@ export class AuthService {
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: {
email: dto.email,
passwordHash,
@@ -48,15 +49,18 @@ export class AuthService {
},
});
await this.prisma.auditLog.create({
await tx.auditLog.create({
data: {
userId: user.id,
userId: u.id,
action: 'USER_REGISTERED',
ip,
userAgent,
},
});
return u;
});
this.eventsService.emit('auth.user.registered', {
userId: user.id,
email: user.email,
@@ -199,17 +203,50 @@ export class AuthService {
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 },
data: { revoked: true },
});
return this.createSession(
stored.session.userId,
stored.session.clientId ?? undefined,
ip ?? stored.session.ip ?? undefined,
userAgent ?? stored.session.userAgent ?? undefined,
);
const now = new Date();
const refreshExpiresIn = this.configService.get<string>(
'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 };
});
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({
await this.prisma.$transaction(async (tx) => {
await tx.refreshToken.update({
where: { id: stored.id },
data: { revoked: true },
});
if (stored.session) {
await this.prisma.session.update({
await tx.session.update({
where: { id: stored.session.id },
data: { isActive: false },
});
}
});
}
}
@@ -275,30 +314,29 @@ 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<string>(
'app.jwt.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: {
tokenHash: refreshTokenHash,
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> {
@@ -317,8 +358,7 @@ export class AuthService {
const options: JwtSignOptions = {
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'),
expiresIn: this.configService.get('app.jwt.accessExpiresIn'),
};
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 {
@IsEmail()
email!: string;
@IsString()
@MinLength(1)
@MaxLength(128)
password!: string;
@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);
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({

View File

@@ -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<string, string>) {
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);

View File

@@ -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<string>('app.jwt.accessSecret'),
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
expiresIn: this.configService.get<any>('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<string>('app.jwt.accessSecret'),
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
expiresIn: this.configService.get<any>('app.jwt.accessExpiresIn'),
} satisfies JwtSignOptions;
expiresIn: this.configService.get('app.jwt.accessExpiresIn'),
};
const accessToken = await this.jwtService.signAsync(
{ sub: clientId, client_id: clientId },
options,

View File

@@ -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 },
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 user = await tx.user.findUnique({
where: { id: s.userId! },
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(
{
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 },
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,
};
});
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' };
}

View File

@@ -19,6 +19,11 @@ export class VerificationService {
}
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();
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<void> {
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<boolean> {
@@ -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');
}