third commit
This commit is contained in:
44
README.md
44
README.md
@@ -42,38 +42,36 @@ Self-hosted SSO / OIDC-сервис — аналог Keycloak / Яндекс ID.
|
||||
- `/revoke`
|
||||
- PKCE support
|
||||
|
||||
## Быстрый старт
|
||||
## Быстрая установка (чистый сервер — всё включено)
|
||||
|
||||
### 1. Инфраструктура
|
||||
**Одна команда — установит Node.js 22, Docker, сам сервис и запустит:**
|
||||
|
||||
```bash
|
||||
curl -fsSL https://github.com/ssomvk/sso-service/raw/main/install.sh | bash
|
||||
```
|
||||
|
||||
Скрипт автоматически:
|
||||
1. Определяет ОС (Ubuntu/Debian/RHEL/CentOS/Fedora)
|
||||
2. Устанавливает **Docker** + **Docker Compose plugin** (если нет)
|
||||
3. Устанавливает **Node.js 22 LTS** (через NodeSource или nvm)
|
||||
4. Скачивает SSO Service в `~/sso-service`
|
||||
5. Генерирует `.env` со случайными JWT-секретами
|
||||
6. Запускает PostgreSQL, Redis, RabbitMQ (ждёт готовности)
|
||||
7. Устанавливает npm-зависимости, Prisma client, применяет миграции
|
||||
8. Сидирует БД (администратор и тестовый OAuth-клиент)
|
||||
9. Собирает и запускает сервис (PM2 или background)
|
||||
|
||||
**Через 2-3 минуты сервис доступен:** http://localhost:3000
|
||||
|
||||
### Ручная установка (если нужен контроль)
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
### 2. База данных
|
||||
|
||||
```bash
|
||||
npx prisma db push
|
||||
```
|
||||
|
||||
### 3. Тестовые данные
|
||||
|
||||
```bash
|
||||
npx ts-node prisma/seed.ts
|
||||
```
|
||||
|
||||
Создаёт:
|
||||
- **Admin:** `admin@sso.local` / `admin123!`
|
||||
- **OAuth Client:** `test-client` (секрет в консоли)
|
||||
|
||||
### 4. Запуск
|
||||
|
||||
```bash
|
||||
npm run start:dev
|
||||
```
|
||||
|
||||
Сервис: `http://localhost:3000`
|
||||
|
||||
## API Endpoints
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|
||||
214
install.sh
214
install.sh
@@ -1,9 +1,10 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
REPO="lendry/sso-mvk"
|
||||
REPO="ssomvk/sso-service"
|
||||
BRANCH="main"
|
||||
INSTALL_DIR="${INSTALL_DIR:-$HOME/sso-service}"
|
||||
SKIP_PREREQS="${SKIP_PREREQS:-false}"
|
||||
|
||||
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; CYAN='\033[0;36m'; NC='\033[0m'
|
||||
info() { echo -e "${GREEN}✔${NC} $1"; }
|
||||
@@ -12,107 +13,232 @@ error() { echo -e "${RED}✘${NC} $1"; exit 1; }
|
||||
step() { echo -e "\n${CYAN}━━━ $1 ━━━${NC}"; }
|
||||
|
||||
echo -e "${CYAN}"
|
||||
echo " ╔═══════════════════════════════════════╗"
|
||||
echo " ║ SSO Service Installer ║"
|
||||
echo " ╚═══════════════════════════════════════╝"
|
||||
echo " ╔══════════════════════════════════════════════╗"
|
||||
echo " ║ SSO Service — Auto Installer ║"
|
||||
echo " ║ https://github.com/${REPO} ║"
|
||||
echo " ╚══════════════════════════════════════════════╝"
|
||||
echo -e "${NC}"
|
||||
|
||||
# ─── Prerequisites ──────────────────────────────────────────────
|
||||
step "1/7 Checking prerequisites"
|
||||
command -v node >/dev/null 2>&1 || error "Node.js required → https://nodejs.org"
|
||||
command -v npm >/dev/null 2>&1 || error "npm required"
|
||||
command -v docker compose >/dev/null 2>&1 || error "Docker Compose required"
|
||||
info "node $(node -v), npm $(npm -v), docker compose found"
|
||||
# ─── Проверка root ──────────────────────────────────────────────
|
||||
if [ "$(id -u)" -eq 0 ]; then
|
||||
SUDO=""
|
||||
else
|
||||
SUDO="sudo"
|
||||
fi
|
||||
|
||||
# ─── Clone / Pull ───────────────────────────────────────────────
|
||||
step "2/7 Downloading SSO Service"
|
||||
# ─── Detecting OS ────────────────────────────────────────────────
|
||||
step "Detecting operating system"
|
||||
OS=""
|
||||
if [ -f /etc/os-release ]; then
|
||||
. /etc/os-release
|
||||
OS="$ID"
|
||||
elif command -v lsb_release &>/dev/null; then
|
||||
OS="$(lsb_release -si | tr '[:upper:]' '[:lower:]')"
|
||||
else
|
||||
OS="$(uname -s)"
|
||||
fi
|
||||
info "Detected: ${OS} ($(uname -m))"
|
||||
|
||||
# ─── 1. Docker ──────────────────────────────────────────────────
|
||||
install_docker() {
|
||||
step "1/8 Installing Docker"
|
||||
if command -v docker &>/dev/null; then
|
||||
info "Docker already installed ($(docker --version))"
|
||||
else
|
||||
warn "Docker not found — installing..."
|
||||
if command -v apt-get &>/dev/null; then
|
||||
curl -fsSL https://get.docker.com | $SUDO sh
|
||||
elif command -v yum &>/dev/null; then
|
||||
curl -fsSL https://get.docker.com | $SUDO sh
|
||||
elif command -v dnf &>/dev/null; then
|
||||
curl -fsSL https://get.docker.com | $SUDO sh
|
||||
else
|
||||
error "Unsupported package manager. Install Docker manually: https://docs.docker.com/engine/install/"
|
||||
fi
|
||||
$SUDO usermod -aG docker "$USER" 2>/dev/null || true
|
||||
info "Docker installed."
|
||||
fi
|
||||
|
||||
# Ensure Docker daemon is running
|
||||
if ! docker info &>/dev/null; then
|
||||
warn "Starting Docker daemon..."
|
||||
$SUDO systemctl enable --now docker 2>/dev/null || $SUDO dockerd &>/dev/null &
|
||||
sleep 3
|
||||
fi
|
||||
info "Docker daemon is running"
|
||||
|
||||
if ! docker compose version &>/dev/null; then
|
||||
warn "Docker Compose not found — installing plugin..."
|
||||
$SUDO mkdir -p /usr/local/lib/docker/cli-plugins
|
||||
$SUDO curl -fsSL "https://github.com/docker/compose/releases/latest/download/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/lib/docker/cli-plugins/docker-compose
|
||||
$SUDO chmod +x /usr/local/lib/docker/cli-plugins/docker-compose
|
||||
fi
|
||||
info "Docker Compose: $(docker compose version 2>&1)"
|
||||
}
|
||||
|
||||
# ─── 2. Node.js ─────────────────────────────────────────────────
|
||||
install_node() {
|
||||
step "2/8 Installing Node.js"
|
||||
if command -v node &>/dev/null; then
|
||||
NODE_MAJOR=$(node -v | sed 's/v//' | cut -d. -f1)
|
||||
if [ "$NODE_MAJOR" -lt 22 ]; then
|
||||
warn "Node.js $(node -v) is too old — upgrading to v22..."
|
||||
else
|
||||
info "Node.js $(node -v) detected"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
if ! command -v node &>/dev/null || [ "$NODE_MAJOR" -lt 22 ]; then
|
||||
warn "Installing Node.js 22 LTS..."
|
||||
if command -v apt-get &>/dev/null; then
|
||||
curl -fsSL https://deb.nodesource.com/setup_22.x | $SUDO bash -
|
||||
$SUDO apt-get install -y nodejs
|
||||
elif command -v dnf &>/dev/null; then
|
||||
curl -fsSL https://rpm.nodesource.com/setup_22.x | $SUDO bash -
|
||||
$SUDO dnf install -y nodejs
|
||||
elif command -v yum &>/dev/null; then
|
||||
curl -fsSL https://rpm.nodesource.com/setup_22.x | $SUDO bash -
|
||||
$SUDO yum install -y nodejs
|
||||
else
|
||||
warn "Package manager not recognised — trying nvm..."
|
||||
curl -fsSL https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash
|
||||
export NVM_DIR="$HOME/.nvm"
|
||||
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
|
||||
nvm install 22
|
||||
nvm alias default 22
|
||||
fi
|
||||
fi
|
||||
info "Node.js $(node -v) ready"
|
||||
}
|
||||
|
||||
# ─── 3. Install prerequisites if needed ──────────────────────────
|
||||
if [ "$SKIP_PREREQS" != "true" ]; then
|
||||
install_docker
|
||||
install_node
|
||||
else
|
||||
step "1-2/8 Skipping prerequisites (SKIP_PREREQS=true)"
|
||||
fi
|
||||
|
||||
# ─── 4. Download project ─────────────────────────────────────────
|
||||
step "3/8 Downloading SSO Service"
|
||||
if [ -d "$INSTALL_DIR/.git" ]; then
|
||||
cd "$INSTALL_DIR"
|
||||
git pull --ff-only 2>/dev/null && info "Updated existing installation" || warn "Could not git pull, continuing"
|
||||
else
|
||||
mkdir -p "$INSTALL_DIR"
|
||||
if command -v git &>/dev/null; then
|
||||
git clone --depth 1 "https://github.com/$REPO.git" "$INSTALL_DIR"
|
||||
git clone --depth 1 "https://github.com/$REPO.git" "$INSTALL_DIR" 2>&1
|
||||
else
|
||||
warn "git not found, downloading archive..."
|
||||
curl -fsSL "https://github.com/$REPO/archive/refs/heads/$BRANCH.tar.gz" | tar xz --strip=1 -C "$INSTALL_DIR"
|
||||
fi
|
||||
info "Downloaded to $INSTALL_DIR"
|
||||
fi
|
||||
cd "$INSTALL_DIR"
|
||||
|
||||
# ─── .env ────────────────────────────────────────────────────────
|
||||
step "3/7 Configuring environment"
|
||||
# ─── 5. .env ─────────────────────────────────────────────────────
|
||||
step "4/8 Configuring environment"
|
||||
if [ ! -f .env ]; then
|
||||
cp .env.example .env
|
||||
sed -i "s/change-me-access-secret-at-least-32-chars/$(openssl rand -hex 32)/" .env
|
||||
sed -i "s/change-me-refresh-secret-at-least-32-chars/$(openssl rand -hex 32)/" .env
|
||||
info "Created .env with random secrets"
|
||||
# Генерация случайных JWT-секретов (32 байта hex = 64 символа)
|
||||
JWT_ACCESS=$(openssl rand -hex 32 2>/dev/null || node -e "process.stdout.write(require('crypto').randomBytes(32).toString('hex'))")
|
||||
JWT_REFRESH=$(openssl rand -hex 32 2>/dev/null || node -e "process.stdout.write(require('crypto').randomBytes(32).toString('hex'))")
|
||||
if [ "$(uname -s)" = "Darwin" ]; then
|
||||
sed -i '' "s/change-me-access-secret-at-least-32-chars/$JWT_ACCESS/" .env
|
||||
sed -i '' "s/change-me-refresh-secret-at-least-32-chars/$JWT_REFRESH/" .env
|
||||
else
|
||||
sed -i "s/change-me-access-secret-at-least-32-chars/$JWT_ACCESS/" .env
|
||||
sed -i "s/change-me-refresh-secret-at-least-32-chars/$JWT_REFRESH/" .env
|
||||
fi
|
||||
info "Created .env with random JWT secrets"
|
||||
else
|
||||
info ".env already exists, keeping it"
|
||||
fi
|
||||
|
||||
# shellcheck source=/dev/null
|
||||
set -a; source .env; set +a
|
||||
|
||||
# ─── Docker ──────────────────────────────────────────────────────
|
||||
step "4/7 Starting infrastructure (PostgreSQL, Redis, RabbitMQ)"
|
||||
docker compose up -d 2>&1
|
||||
# ─── 6. Docker infrastructure ────────────────────────────────────
|
||||
step "5/8 Starting infrastructure (PostgreSQL, Redis, RabbitMQ)"
|
||||
$SUDO docker compose up -d 2>&1
|
||||
|
||||
info "Waiting for PostgreSQL..."
|
||||
for i in $(seq 1 30); do
|
||||
if docker compose exec -T postgres pg_isready -U "${POSTGRES_USER}" -d "${POSTGRES_DB}" &>/dev/null; then
|
||||
if $SUDO docker compose exec -T postgres pg_isready -U "${POSTGRES_USER}" -d "${POSTGRES_DB}" &>/dev/null; then
|
||||
info "PostgreSQL is ready"
|
||||
break
|
||||
fi
|
||||
[ "$i" -eq 30 ] && error "PostgreSQL did not start in time. Check: docker compose logs postgres"
|
||||
[ "$i" -eq 30 ] && error "PostgreSQL did not start in time. Check: $SUDO docker compose logs postgres"
|
||||
sleep 2
|
||||
done
|
||||
|
||||
# ─── Dependencies & Prisma ──────────────────────────────────────
|
||||
step "5/7 Installing dependencies and generating Prisma client"
|
||||
info "Waiting for Redis..."
|
||||
for i in $(seq 1 15); do
|
||||
if $SUDO docker compose exec -T redis redis-cli ping 2>/dev/null | grep -q PONG; then
|
||||
info "Redis is ready"
|
||||
break
|
||||
fi
|
||||
[ "$i" -eq 15 ] && warn "Redis may not be ready yet, continuing..."
|
||||
sleep 2
|
||||
done
|
||||
|
||||
# ─── 7. Dependencies & Prisma ────────────────────────────────────
|
||||
step "6/8 Installing dependencies"
|
||||
npm ci --no-audit --no-fund 2>&1 | tail -1
|
||||
info "npm dependencies installed"
|
||||
|
||||
npx prisma generate 2>&1 | tail -1
|
||||
info "Prisma client generated"
|
||||
|
||||
export DATABASE_URL
|
||||
npx prisma db push --skip-generate 2>&1
|
||||
info "Database schema applied"
|
||||
|
||||
npx ts-node prisma/seed.ts 2>&1
|
||||
# ─── 8. Seed ─────────────────────────────────────────────────────
|
||||
step "7/8 Seeding database"
|
||||
npx ts-node -P tsconfig.json prisma/seed.ts 2>&1
|
||||
info "Seed data created"
|
||||
|
||||
# ─── Build ──────────────────────────────────────────────────────
|
||||
echo ""
|
||||
# ─── 9. Build & Start ────────────────────────────────────────────
|
||||
step "8/8 Building and starting SSO Service"
|
||||
npm run build 2>&1 | tail -3
|
||||
info "Application built"
|
||||
|
||||
# ─── Start ──────────────────────────────────────────────────────
|
||||
step "6/7 Starting SSO Service"
|
||||
if command -v pm2 &>/dev/null; then
|
||||
pm2 delete sso-service 2>/dev/null || true
|
||||
pm2 start npm --name "sso-service" -- run start:prod
|
||||
pm2 save
|
||||
pm2 save 2>/dev/null || true
|
||||
info "Started with PM2"
|
||||
else
|
||||
PORT="${PORT:-3000}" nohup node dist/main > sso.log 2>&1 &
|
||||
echo $! > .sso.pid
|
||||
info "Started in background (PID $(cat .sso.pid)), logs: sso.log"
|
||||
PORT="${PORT:-3000}" nohup node dist/main > "$INSTALL_DIR/sso.log" 2>&1 &
|
||||
echo $! > "$INSTALL_DIR/.sso.pid"
|
||||
info "Started in background (PID $(cat "$INSTALL_DIR/.sso.pid")) — logs: $INSTALL_DIR/sso.log"
|
||||
fi
|
||||
|
||||
sleep 3
|
||||
if curl -sfo /dev/null http://localhost:"${PORT:-3000}"/metrics 2>/dev/null; then
|
||||
if curl -sfo /dev/null "http://localhost:${PORT:-3000}/metrics" 2>/dev/null; then
|
||||
info "Service is running on http://localhost:${PORT:-3000}"
|
||||
else
|
||||
warn "Service may still be starting... check sso.log"
|
||||
warn "Service may still be starting... check: $INSTALL_DIR/sso.log"
|
||||
fi
|
||||
|
||||
# ─── Done ────────────────────────────────────────────────────────
|
||||
step "7/7 Installation complete!"
|
||||
echo ""
|
||||
echo -e " ${GREEN}Web panel:${NC} http://localhost:${PORT:-3000}"
|
||||
echo -e " ${GREEN}Admin login:${NC} admin@sso.local / admin123!"
|
||||
echo -e " ${GREEN}Metrics:${NC} http://localhost:${PORT:-3000}/metrics"
|
||||
echo -e " ${GREEN}RabbitMQ UI:${NC} http://localhost:15672 (sso / sso_secret)"
|
||||
echo -e "${GREEN}════════════════════════════════════════════════${NC}"
|
||||
echo -e "${GREEN} SSO Service installed and running!${NC}"
|
||||
echo -e "${GREEN}════════════════════════════════════════════════${NC}"
|
||||
echo ""
|
||||
echo -e " ${YELLOW}To stop:${NC} kill \$(cat $INSTALL_DIR/.sso.pid) (or pm2 stop sso-service)"
|
||||
echo -e " ${YELLOW}To update:${NC} cd $INSTALL_DIR && git pull && npm ci && npm run build && npm run start:prod"
|
||||
echo -e " ${CYAN}Web panel:${NC} http://localhost:${PORT:-3000}"
|
||||
echo -e " ${CYAN}Admin login:${NC} admin@sso.local / admin123!"
|
||||
echo -e " ${CYAN}Metrics:${NC} http://localhost:${PORT:-3000}/metrics"
|
||||
echo -e " ${CYAN}RabbitMQ UI:${NC} http://localhost:15672 (${RABBITMQ_USER:-sso} / ${RABBITMQ_PASSWORD:-sso_secret})"
|
||||
echo ""
|
||||
echo -e " ${YELLOW}Install dir:${NC} $INSTALL_DIR"
|
||||
echo -e " ${YELLOW}Log file:${NC} $INSTALL_DIR/sso.log"
|
||||
echo ""
|
||||
echo -e " ${YELLOW}Stop:${NC} $(command -v pm2 &>/dev/null && echo 'pm2 stop sso-service' || echo "kill \$(cat $INSTALL_DIR/.sso.pid)")"
|
||||
echo -e " ${YELLOW}Update:${NC} cd $INSTALL_DIR && git pull && npm ci && npm run build && npm run start:prod"
|
||||
echo -e " ${YELLOW}Re-run:${NC} curl -fsSL https://github.com/${REPO}/raw/${BRANCH}/install.sh | bash"
|
||||
echo ""
|
||||
echo -e " ${GREEN}Open http://localhost:${PORT:-3000} in your browser${NC}"
|
||||
echo ""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
],
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
9
src/auth/dto/login-email-code.dto.ts
Normal file
9
src/auth/dto/login-email-code.dto.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { IsEmail, IsString } from 'class-validator';
|
||||
|
||||
export class LoginEmailCodeDto {
|
||||
@IsEmail()
|
||||
email!: string;
|
||||
|
||||
@IsString()
|
||||
code!: string;
|
||||
}
|
||||
9
src/auth/dto/login-phone-code.dto.ts
Normal file
9
src/auth/dto/login-phone-code.dto.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { IsString } from 'class-validator';
|
||||
|
||||
export class LoginPhoneCodeDto {
|
||||
@IsString()
|
||||
phone!: string;
|
||||
|
||||
@IsString()
|
||||
code!: string;
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
6
src/auth/dto/qr-poll.dto.ts
Normal file
6
src/auth/dto/qr-poll.dto.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { IsString } from 'class-validator';
|
||||
|
||||
export class QrPollDto {
|
||||
@IsString()
|
||||
sessionId!: string;
|
||||
}
|
||||
11
src/auth/dto/send-code.dto.ts
Normal file
11
src/auth/dto/send-code.dto.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { IsEmail, IsString } from 'class-validator';
|
||||
|
||||
export class SendEmailCodeDto {
|
||||
@IsEmail()
|
||||
email!: string;
|
||||
}
|
||||
|
||||
export class SendPhoneCodeDto {
|
||||
@IsString()
|
||||
phone!: string;
|
||||
}
|
||||
15
src/auth/dto/update-profile.dto.ts
Normal file
15
src/auth/dto/update-profile.dto.ts
Normal 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;
|
||||
}
|
||||
15
src/main.ts
15
src/main.ts
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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' };
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user