first commit
This commit is contained in:
9
.dockerignore
Normal file
9
.dockerignore
Normal file
@@ -0,0 +1,9 @@
|
||||
node_modules
|
||||
dist
|
||||
.git
|
||||
.gitignore
|
||||
.env
|
||||
.env.example
|
||||
*.md
|
||||
coverage
|
||||
test
|
||||
63
.env.example
Normal file
63
.env.example
Normal file
@@ -0,0 +1,63 @@
|
||||
# ──────────────────────────────────────────────
|
||||
# Сервис
|
||||
# ──────────────────────────────────────────────
|
||||
NODE_ENV=development
|
||||
PORT=3000
|
||||
HOST=0.0.0.0
|
||||
TRUST_PROXY=1
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# JWT
|
||||
# ──────────────────────────────────────────────
|
||||
JWT_ACCESS_SECRET=change-me-access-secret-at-least-32-chars
|
||||
JWT_REFRESH_SECRET=change-me-refresh-secret-at-least-32-chars
|
||||
JWT_ACCESS_EXPIRES_IN=15m
|
||||
JWT_REFRESH_EXPIRES_IN=7d
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# База данных (PostgreSQL)
|
||||
# ──────────────────────────────────────────────
|
||||
POSTGRES_USER=sso
|
||||
POSTGRES_PASSWORD=sso_secret
|
||||
POSTGRES_DB=sso_db
|
||||
DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# Redis
|
||||
# ──────────────────────────────────────────────
|
||||
REDIS_HOST=localhost
|
||||
REDIS_PORT=6379
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# RabbitMQ
|
||||
# ──────────────────────────────────────────────
|
||||
RABBITMQ_USER=sso
|
||||
RABBITMQ_PASSWORD=sso_secret
|
||||
RABBITMQ_HOST=localhost
|
||||
RABBITMQ_PORT=5672
|
||||
RABBITMQ_URL=amqp://${RABBITMQ_USER}:${RABBITMQ_PASSWORD}@${RABBITMQ_HOST}:${RABBITMQ_PORT}
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# CORS
|
||||
# ──────────────────────────────────────────────
|
||||
CORS_ORIGINS=http://localhost:3000,http://localhost:5173
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# Rate Limiting
|
||||
# ──────────────────────────────────────────────
|
||||
THROTTLE_TTL=60
|
||||
THROTTLE_LIMIT=10
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# SMTP (Email)
|
||||
# ──────────────────────────────────────────────
|
||||
SMTP_HOST=localhost
|
||||
SMTP_PORT=1025
|
||||
SMTP_USER=
|
||||
SMTP_PASSWORD=
|
||||
SMTP_FROM=noreply@sso.local
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# SMS (mock)
|
||||
# ──────────────────────────────────────────────
|
||||
SMS_PROVIDER=mock
|
||||
16
.gitignore
vendored
Normal file
16
.gitignore
vendored
Normal file
@@ -0,0 +1,16 @@
|
||||
node_modules/
|
||||
dist/
|
||||
coverage/
|
||||
.env
|
||||
*.log
|
||||
.sso.pid
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
4
.prettierrc
Normal file
4
.prettierrc
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all"
|
||||
}
|
||||
21
Dockerfile
Normal file
21
Dockerfile
Normal file
@@ -0,0 +1,21 @@
|
||||
FROM node:22-alpine AS builder
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
COPY prisma ./prisma
|
||||
RUN npx prisma generate
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM node:22-alpine AS runner
|
||||
WORKDIR /app
|
||||
RUN addgroup --system --gid 1001 nodejs && \
|
||||
adduser --system --uid 1001 sso
|
||||
COPY --from=builder /app/dist ./dist
|
||||
COPY --from=builder /app/node_modules ./node_modules
|
||||
COPY --from=builder /app/package.json ./
|
||||
COPY --from=builder /app/prisma ./prisma
|
||||
USER sso
|
||||
EXPOSE 3000
|
||||
ENV NODE_ENV=production
|
||||
CMD ["node", "dist/main"]
|
||||
264
README.md
Normal file
264
README.md
Normal file
@@ -0,0 +1,264 @@
|
||||
# SSO Service
|
||||
|
||||
Self-hosted SSO / OIDC-сервис — аналог Keycloak / Яндекс ID. Node.js (NestJS).
|
||||
|
||||
## Технологии
|
||||
|
||||
- **Runtime:** Node.js 22, NestJS 11
|
||||
- **Database:** PostgreSQL 16 + Prisma ORM
|
||||
- **Cache:** Redis 7
|
||||
- **Message Broker:** RabbitMQ 4
|
||||
- **Logging:** Pino (JSON, nestjs-pino)
|
||||
- **Metrics:** Prometheus
|
||||
- **Security:** Argon2, JWT (Access + Refresh), Helmet, OIDC (authorization_code, PKCE)
|
||||
- **Templates:** EJS + Bootstrap 5
|
||||
|
||||
## Возможности
|
||||
|
||||
### Аутентификация
|
||||
- Логин/пароль (Argon2)
|
||||
- Вход по коду на email
|
||||
- Вход по коду на телефон
|
||||
- QR-авторизация (для мобильного приложения)
|
||||
- Refresh + rotate токенов
|
||||
|
||||
### Веб-панель
|
||||
- `/login` — страница входа (пароль, код на email, QR)
|
||||
- `/register` — регистрация
|
||||
- `/profile` — личный кабинет (редактирование профиля)
|
||||
- `/admin` — админ-панель:
|
||||
- Статистика (пользователи, клиенты, сессии)
|
||||
- CRUD пользователей
|
||||
- CRUD OAuth-клиентов (как oauth.yandex.ru)
|
||||
|
||||
### Роли
|
||||
- **USER** — только свой профиль
|
||||
- **ADMIN** — управление пользователями и OAuth-клиентами
|
||||
|
||||
### OIDC / OAuth2
|
||||
- `/authorize` (authorization_code)
|
||||
- `/token` (authorization_code, refresh_token, client_credentials)
|
||||
- `/userinfo`
|
||||
- `/revoke`
|
||||
- PKCE support
|
||||
|
||||
## Быстрый старт
|
||||
|
||||
### 1. Инфраструктура
|
||||
|
||||
```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 |
|
||||
|--------|------|------|-------------|
|
||||
| POST | /api/auth/register | No | Регистрация |
|
||||
| POST | /api/auth/login | No | Логин (пароль) |
|
||||
| POST | /api/auth/login/email-code | No | Логин (код на email) |
|
||||
| POST | /api/auth/login/phone-code | No | Логин (код на телефон) |
|
||||
| POST | /api/auth/send-email-code | No | Отправить код на email |
|
||||
| POST | /api/auth/send-phone-code | No | Отправить код на телефон |
|
||||
| POST | /api/auth/qr/init | No | Создать QR-сессию |
|
||||
| POST | /api/auth/qr/poll | No | Проверить QR-сессию |
|
||||
| POST | /api/auth/refresh | No | Обновить токен |
|
||||
| POST | /api/auth/logout | No | Выйти |
|
||||
| GET | /api/auth/profile | JWT | Профиль |
|
||||
| POST | /api/auth/profile/update | JWT | Обновить профиль |
|
||||
| GET | /api/authorize | No | OAuth2 authorize |
|
||||
| POST | /api/token | No | OAuth2 token |
|
||||
| GET | /api/userinfo | Bearer | OIDC userinfo |
|
||||
| POST | /api/revoke | No | Отозвать токен |
|
||||
| GET | /api/admin/stats | JWT+ADMIN | Статистика |
|
||||
| GET | /api/admin/users | JWT+ADMIN | Список пользователей |
|
||||
| POST | /api/admin/users | JWT+ADMIN | Создать пользователя |
|
||||
| PUT | /api/admin/users/:id | JWT+ADMIN | Обновить пользователя |
|
||||
| DELETE | /api/admin/users/:id | JWT+ADMIN | Удалить пользователя |
|
||||
| GET | /api/admin/clients | JWT+ADMIN | Список OAuth-клиентов |
|
||||
| POST | /api/admin/clients | JWT+ADMIN | Создать OAuth-клиент |
|
||||
| PUT | /api/admin/clients/:id | JWT+ADMIN | Обновить OAuth-клиент |
|
||||
| DELETE | /api/admin/clients/:id | JWT+ADMIN | Удалить OAuth-клиент |
|
||||
| GET | /metrics | No | Prometheus метрики |
|
||||
|
||||
## Веб-панель
|
||||
|
||||
| Страница | URL | Доступ |
|
||||
|----------|-----|--------|
|
||||
| Логин | /login | Все |
|
||||
| Регистрация | /register | Все |
|
||||
| Профиль | /profile | Авторизованные |
|
||||
| Админка (пользователи) | /admin | ADMIN |
|
||||
| Админка (клиенты) | /admin/clients | ADMIN |
|
||||
|
||||
## Примеры cURL
|
||||
|
||||
### Регистрация
|
||||
```bash
|
||||
curl -X POST http://localhost:3000/api/auth/register \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"email":"user@example.com","password":"secret1234","displayName":"Test"}'
|
||||
```
|
||||
|
||||
### Логин по паролю
|
||||
```bash
|
||||
curl -X POST http://localhost:3000/api/auth/login \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"email":"user@example.com","password":"secret1234"}'
|
||||
```
|
||||
|
||||
### Логин по коду на email
|
||||
```bash
|
||||
# Шаг 1: запросить код
|
||||
curl -X POST http://localhost:3000/api/auth/send-email-code \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"email":"user@example.com"}'
|
||||
|
||||
# Шаг 2: войти с кодом
|
||||
curl -X POST http://localhost:3000/api/auth/login/email-code \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"email":"user@example.com","code":"123456"}'
|
||||
```
|
||||
|
||||
### QR-авторизация
|
||||
```bash
|
||||
# Инициализация
|
||||
curl -X POST http://localhost:3000/api/auth/qr/init
|
||||
|
||||
# Проверка статуса
|
||||
curl -X POST http://localhost:3000/api/auth/qr/poll \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"sessionId":"<sessionId>"}'
|
||||
```
|
||||
|
||||
### Админка (список пользователей)
|
||||
```bash
|
||||
curl http://localhost:3000/api/admin/users \
|
||||
-H "Authorization: Bearer <admin_token>"
|
||||
```
|
||||
|
||||
### Метрики
|
||||
```bash
|
||||
curl http://localhost:3000/metrics
|
||||
```
|
||||
|
||||
### RabbitMQ Management
|
||||
```
|
||||
http://localhost:15672
|
||||
Login: sso / sso_secret
|
||||
```
|
||||
|
||||
## Переменные окружения
|
||||
|
||||
Скопируйте `.env.example` в `.env`:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Все переменные строго валидируются (Zod). Fast Fail.
|
||||
|
||||
## Быстрая установка одной командой
|
||||
|
||||
```bash
|
||||
curl -fsSL https://github.com/ssomvk/sso-service/raw/main/install.sh | bash
|
||||
```
|
||||
|
||||
Скрипт автоматически:
|
||||
1. Проверяет Node.js, Docker, Docker Compose
|
||||
2. Скачивает проект в `~/sso-service`
|
||||
3. Создаёт `.env` со случайными JWT-секретами
|
||||
4. Запускает PostgreSQL, Redis, RabbitMQ через Docker
|
||||
5. Устанавливает зависимости, применяет миграции, сидирует БД
|
||||
6. Собирает и запускает сервис
|
||||
|
||||
После установки:
|
||||
- **Веб-панель:** http://localhost:3000
|
||||
- **Админ:** `admin@sso.local` / `admin123!`
|
||||
- **Метрики:** http://localhost:3000/metrics
|
||||
- **RabbitMQ UI:** http://localhost:15672 (`sso` / `sso_secret`)
|
||||
|
||||
### Переменные окружения (опционально)
|
||||
```bash
|
||||
INSTALL_DIR=/opt/sso curl -fsSL https://github.com/ssomvk/sso-service/raw/main/install.sh | bash
|
||||
```
|
||||
|
||||
## Ручная установка
|
||||
|
||||
```bash
|
||||
# 1. Клонировать
|
||||
git clone https://github.com/ssomvk/sso-service.git
|
||||
cd sso-service
|
||||
|
||||
# 2. Настроить окружение
|
||||
cp .env.example .env
|
||||
# Отредактировать .env под себя
|
||||
|
||||
# 3. Запустить инфраструктуру
|
||||
docker compose up -d
|
||||
|
||||
# 4. Установить зависимости и применить миграции
|
||||
npm ci
|
||||
npx prisma generate
|
||||
npx prisma db push
|
||||
npx ts-node prisma/seed.ts
|
||||
|
||||
# 5. Собрать и запустить
|
||||
npm run build
|
||||
node dist/main
|
||||
```
|
||||
|
||||
## Production-деплой
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
node dist/main
|
||||
```
|
||||
|
||||
Корректно обрабатывает X-Forwarded-For (TRUST_PROXY).
|
||||
|
||||
## Публикация на GitHub
|
||||
|
||||
```bash
|
||||
# 1. Создать репозиторий на github.com → New repository
|
||||
# Название: sso-service (или любое другое)
|
||||
# Visibility: Public
|
||||
|
||||
# 2. Инициализировать git и запушить
|
||||
cd sso-service
|
||||
git init
|
||||
git add -A
|
||||
git commit -m "Initial commit: SSO service (NestJS, Prisma 7, PostgreSQL)"
|
||||
git branch -M main
|
||||
git remote add origin https://github.com/ВАШ_ЛОГИН/sso-service.git
|
||||
git push -u origin main
|
||||
|
||||
# 3. Установка с любого сервера:
|
||||
curl -fsSL https://github.com/ВАШ_ЛОГИН/sso-service/raw/main/install.sh | bash
|
||||
```
|
||||
|
||||
**Важно:** Перед пушем отредактируйте `install.sh` — замените `REPO="ssomvk/sso-service"` на ваш репозиторий (строка 4).
|
||||
48
docker-compose.yml
Normal file
48
docker-compose.yml
Normal file
@@ -0,0 +1,48 @@
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
restart: unless-stopped
|
||||
env_file: .env
|
||||
environment:
|
||||
POSTGRES_USER: ${POSTGRES_USER}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
POSTGRES_DB: ${POSTGRES_DB}
|
||||
ports:
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "6379:6379"
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
rabbitmq:
|
||||
image: rabbitmq:4-management-alpine
|
||||
restart: unless-stopped
|
||||
env_file: .env
|
||||
environment:
|
||||
RABBITMQ_DEFAULT_USER: ${RABBITMQ_USER}
|
||||
RABBITMQ_DEFAULT_PASS: ${RABBITMQ_PASSWORD}
|
||||
ports:
|
||||
- "5672:5672"
|
||||
- "15672:15672"
|
||||
healthcheck:
|
||||
test: ["CMD", "rabbitmq-diagnostics", "check_port_connectivity"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
35
eslint.config.mjs
Normal file
35
eslint.config.mjs
Normal file
@@ -0,0 +1,35 @@
|
||||
// @ts-check
|
||||
import eslint from '@eslint/js';
|
||||
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended';
|
||||
import globals from 'globals';
|
||||
import tseslint from 'typescript-eslint';
|
||||
|
||||
export default tseslint.config(
|
||||
{
|
||||
ignores: ['eslint.config.mjs'],
|
||||
},
|
||||
eslint.configs.recommended,
|
||||
...tseslint.configs.recommendedTypeChecked,
|
||||
eslintPluginPrettierRecommended,
|
||||
{
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.node,
|
||||
...globals.jest,
|
||||
},
|
||||
sourceType: 'commonjs',
|
||||
parserOptions: {
|
||||
projectService: true,
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
rules: {
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'@typescript-eslint/no-floating-promises': 'warn',
|
||||
'@typescript-eslint/no-unsafe-argument': 'warn',
|
||||
"prettier/prettier": ["error", { endOfLine: "auto" }],
|
||||
},
|
||||
},
|
||||
);
|
||||
118
install.sh
Normal file
118
install.sh
Normal file
@@ -0,0 +1,118 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
REPO="ssomvk/sso-service"
|
||||
BRANCH="main"
|
||||
INSTALL_DIR="${INSTALL_DIR:-$HOME/sso-service}"
|
||||
|
||||
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"; }
|
||||
warn() { echo -e "${YELLOW}⚠${NC} $1"; }
|
||||
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 -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"
|
||||
|
||||
# ─── Clone / Pull ───────────────────────────────────────────────
|
||||
step "2/7 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"
|
||||
else
|
||||
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"
|
||||
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"
|
||||
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
|
||||
|
||||
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
|
||||
info "PostgreSQL is ready"
|
||||
break
|
||||
fi
|
||||
[ "$i" -eq 30 ] && error "PostgreSQL did not start in time. Check: docker compose logs postgres"
|
||||
sleep 2
|
||||
done
|
||||
|
||||
# ─── Dependencies & Prisma ──────────────────────────────────────
|
||||
step "5/7 Installing dependencies and generating Prisma client"
|
||||
npm ci --no-audit --no-fund 2>&1 | tail -1
|
||||
npx prisma generate 2>&1 | tail -1
|
||||
|
||||
export DATABASE_URL
|
||||
npx prisma db push --skip-generate 2>&1
|
||||
info "Database schema applied"
|
||||
|
||||
npx ts-node prisma/seed.ts 2>&1
|
||||
info "Seed data created"
|
||||
|
||||
# ─── Build ──────────────────────────────────────────────────────
|
||||
echo ""
|
||||
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
|
||||
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"
|
||||
fi
|
||||
|
||||
sleep 3
|
||||
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"
|
||||
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 ""
|
||||
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 ""
|
||||
8
nest-cli.json
Normal file
8
nest-cli.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
||||
12424
package-lock.json
generated
Normal file
12424
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
107
package.json
Normal file
107
package.json
Normal file
@@ -0,0 +1,107 @@
|
||||
{
|
||||
"name": "sso-service",
|
||||
"version": "0.0.1",
|
||||
"description": "Self-hosted SSO / OIDC service (Node.js, NestJS)",
|
||||
"author": "",
|
||||
"private": true,
|
||||
"license": "UNLICENSED",
|
||||
"scripts": {
|
||||
"build": "nest build",
|
||||
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
||||
"start": "nest start",
|
||||
"start:dev": "nest start --watch",
|
||||
"start:debug": "nest start --debug --watch",
|
||||
"start:prod": "node dist/main",
|
||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
"test:cov": "jest --coverage",
|
||||
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
||||
"test:e2e": "jest --config ./test/jest-e2e.json",
|
||||
"prisma:generate": "prisma generate",
|
||||
"prisma:push": "prisma db push",
|
||||
"prisma:migrate": "prisma migrate dev",
|
||||
"prisma:studio": "prisma studio"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nestjs/common": "^11.0.1",
|
||||
"@nestjs/config": "^4.0.0",
|
||||
"@nestjs/core": "^11.0.1",
|
||||
"@nestjs/jwt": "^11.0.0",
|
||||
"@nestjs/microservices": "^11.0.1",
|
||||
"@nestjs/passport": "^11.0.5",
|
||||
"@nestjs/platform-express": "^11.0.1",
|
||||
"@nestjs/throttler": "^6.4.0",
|
||||
"@prisma/adapter-pg": "^7.8.0",
|
||||
"@prisma/client": "^7.8.0",
|
||||
"@types/ejs": "^3.1.5",
|
||||
"@types/nodemailer": "^8.0.0",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@willsoto/nestjs-prometheus": "^6.0.2",
|
||||
"argon2": "^0.41.1",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.1",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"ejs": "^6.0.1",
|
||||
"helmet": "^8.0.0",
|
||||
"ioredis": "^5.5.0",
|
||||
"nestjs-pino": "^4.1.0",
|
||||
"nodemailer": "^8.0.11",
|
||||
"passport": "^0.7.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"pg": "^8.21.0",
|
||||
"pino": "^9.6.0",
|
||||
"prom-client": "^15.1.3",
|
||||
"qrcode": "^1.5.4",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1",
|
||||
"zod": "^3.24.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3.2.0",
|
||||
"@eslint/js": "^9.18.0",
|
||||
"@nestjs/cli": "^11.0.0",
|
||||
"@nestjs/schematics": "^11.0.0",
|
||||
"@nestjs/testing": "^11.0.1",
|
||||
"@types/cookie-parser": "^1.4.8",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/node": "^24.0.0",
|
||||
"@types/passport-jwt": "^4.0.1",
|
||||
"@types/supertest": "^7.0.0",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"eslint": "^9.18.0",
|
||||
"eslint-config-prettier": "^10.0.1",
|
||||
"eslint-plugin-prettier": "^5.2.2",
|
||||
"globals": "^17.0.0",
|
||||
"jest": "^30.0.0",
|
||||
"pino-pretty": "^13.0.0",
|
||||
"prettier": "^3.4.2",
|
||||
"prisma": "^7.8.0",
|
||||
"source-map-support": "^0.5.21",
|
||||
"supertest": "^7.0.0",
|
||||
"ts-jest": "^29.2.5",
|
||||
"ts-loader": "^9.5.2",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"typescript": "^5.7.3",
|
||||
"typescript-eslint": "^8.20.0"
|
||||
},
|
||||
"jest": {
|
||||
"moduleFileExtensions": [
|
||||
"js",
|
||||
"json",
|
||||
"ts"
|
||||
],
|
||||
"rootDir": "src",
|
||||
"testRegex": ".*\\.spec\\.ts$",
|
||||
"transform": {
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
},
|
||||
"collectCoverageFrom": [
|
||||
"**/*.(t|j)s"
|
||||
],
|
||||
"coverageDirectory": "../coverage",
|
||||
"testEnvironment": "node"
|
||||
}
|
||||
}
|
||||
8
prisma.config.ts
Normal file
8
prisma.config.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from "@prisma/config";
|
||||
|
||||
export default defineConfig({
|
||||
schema: "./prisma/schema.prisma",
|
||||
datasource: {
|
||||
url: process.env.DATABASE_URL!,
|
||||
},
|
||||
});
|
||||
139
prisma/schema.prisma
Normal file
139
prisma/schema.prisma
Normal file
@@ -0,0 +1,139 @@
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
}
|
||||
|
||||
enum Role {
|
||||
USER
|
||||
ADMIN
|
||||
}
|
||||
|
||||
enum QrStatus {
|
||||
PENDING
|
||||
SCANNED
|
||||
CONFIRMED
|
||||
EXPIRED
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
email String @unique
|
||||
phone String? @unique
|
||||
passwordHash String? @map("password_hash")
|
||||
displayName String? @map("display_name")
|
||||
avatarUrl String? @map("avatar_url")
|
||||
role Role @default(USER)
|
||||
isActive Boolean @default(true) @map("is_active")
|
||||
emailVerifiedAt DateTime? @map("email_verified_at")
|
||||
phoneVerifiedAt DateTime? @map("phone_verified_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
ownedClients Client[] @relation("ClientOwner")
|
||||
sessions Session[]
|
||||
auditLogs AuditLog[]
|
||||
verificationCodes VerificationCode[]
|
||||
qrSessions QrSession[]
|
||||
|
||||
@@map("users")
|
||||
}
|
||||
|
||||
model Client {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
name String
|
||||
description String?
|
||||
clientId String @unique @map("client_id")
|
||||
clientSecret String? @map("client_secret")
|
||||
redirectUris String[] @map("redirect_uris")
|
||||
grants String[]
|
||||
isConfidential Boolean @default(true) @map("is_confidential")
|
||||
ownerId String? @map("owner_id") @db.Uuid
|
||||
logoUrl String? @map("logo_url")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
owner User? @relation("ClientOwner", fields: [ownerId], references: [id], onDelete: SetNull)
|
||||
sessions Session[]
|
||||
|
||||
@@map("clients")
|
||||
}
|
||||
|
||||
model Session {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
userId String @map("user_id") @db.Uuid
|
||||
clientId String? @map("client_id") @db.Uuid
|
||||
ip String?
|
||||
userAgent String? @map("user_agent")
|
||||
isActive Boolean @default(true) @map("is_active")
|
||||
expiresAt DateTime @map("expires_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
lastUsedAt DateTime @default(now()) @map("last_used_at")
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
client Client? @relation(fields: [clientId], references: [id], onDelete: SetNull)
|
||||
refreshTokens RefreshToken[]
|
||||
|
||||
@@map("sessions")
|
||||
}
|
||||
|
||||
model RefreshToken {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
tokenHash String @unique @map("token_hash")
|
||||
sessionId String @map("session_id") @db.Uuid
|
||||
expiresAt DateTime @map("expires_at")
|
||||
revoked Boolean @default(false)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
session Session @relation(fields: [sessionId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@map("refresh_tokens")
|
||||
}
|
||||
|
||||
model VerificationCode {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
userId String? @map("user_id") @db.Uuid
|
||||
target String
|
||||
channel String
|
||||
code String
|
||||
purpose String @default("AUTH")
|
||||
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: Cascade)
|
||||
|
||||
@@map("verification_codes")
|
||||
}
|
||||
|
||||
model QrSession {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
sessionId String @unique @map("session_id")
|
||||
userId String? @map("user_id") @db.Uuid
|
||||
status QrStatus @default(PENDING)
|
||||
qrData String? @map("qr_data")
|
||||
deviceInfo String? @map("device_info")
|
||||
expiresAt DateTime @map("expires_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
confirmedAt DateTime? @map("confirmed_at")
|
||||
|
||||
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@map("qr_sessions")
|
||||
}
|
||||
|
||||
model AuditLog {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
userId String? @map("user_id") @db.Uuid
|
||||
action String
|
||||
details Json?
|
||||
ip String?
|
||||
userAgent String? @map("user_agent")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@map("audit_logs")
|
||||
}
|
||||
49
prisma/seed.ts
Normal file
49
prisma/seed.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { PrismaClient, Role } from "@prisma/client";
|
||||
import * as argon2 from "argon2";
|
||||
import { randomBytes } from "node:crypto";
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const prisma = new PrismaClient({ datasourceUrl: process.env.DATABASE_URL });
|
||||
|
||||
const adminEmail = "admin@sso.local";
|
||||
const adminPassword = "admin123!";
|
||||
|
||||
const existingAdmin = await prisma.user.findUnique({ where: { email: adminEmail } });
|
||||
if (!existingAdmin) {
|
||||
const passwordHash = await argon2.hash(adminPassword);
|
||||
await prisma.user.create({
|
||||
data: {
|
||||
email: adminEmail,
|
||||
passwordHash,
|
||||
displayName: "SSO Admin",
|
||||
role: Role.ADMIN,
|
||||
},
|
||||
});
|
||||
console.log(`Admin user created: ${adminEmail} / ${adminPassword}`);
|
||||
}
|
||||
|
||||
const testClientId = "test-client";
|
||||
const existingClient = await prisma.client.findUnique({ where: { clientId: testClientId } });
|
||||
if (!existingClient) {
|
||||
const clientSecret = randomBytes(32).toString("hex");
|
||||
await prisma.client.create({
|
||||
data: {
|
||||
name: "Test OAuth Client",
|
||||
clientId: testClientId,
|
||||
clientSecret,
|
||||
redirectUris: ["http://localhost:5173/callback", "http://localhost:3000/callback"],
|
||||
grants: ["authorization_code", "client_credentials", "refresh_token"],
|
||||
isConfidential: true,
|
||||
},
|
||||
});
|
||||
console.log(`OAuth client created: ${testClientId} / ${clientSecret}`);
|
||||
}
|
||||
|
||||
console.log("Seed complete.");
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error("Seed failed:", e);
|
||||
process.exit(1);
|
||||
});
|
||||
4
public/css/style.css
Normal file
4
public/css/style.css
Normal file
@@ -0,0 +1,4 @@
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; }
|
||||
.card { border-radius: 12px; border: none; }
|
||||
.card-header { border-radius: 12px 12px 0 0 !important; }
|
||||
.btn { border-radius: 8px; }
|
||||
94
src/admin/admin.controller.ts
Normal file
94
src/admin/admin.controller.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Put,
|
||||
Delete,
|
||||
Body,
|
||||
Param,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { Role } from '@prisma/client';
|
||||
import { Roles } from '../common/decorators/roles.decorator';
|
||||
import { RolesGuard } from '../common/guards/roles.guard';
|
||||
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) {}
|
||||
|
||||
@Get('stats')
|
||||
async stats() {
|
||||
return this.adminService.getStats();
|
||||
}
|
||||
|
||||
@Get('users')
|
||||
async listUsers(
|
||||
@Query('page') page?: string,
|
||||
@Query('limit') limit?: string,
|
||||
) {
|
||||
return this.adminService.listUsers(
|
||||
page ? parseInt(page, 10) : 1,
|
||||
limit ? parseInt(limit, 10) : 20,
|
||||
);
|
||||
}
|
||||
|
||||
@Get('users/:id')
|
||||
async getUser(@Param('id') id: string) {
|
||||
return this.adminService.getUser(id);
|
||||
}
|
||||
|
||||
@Post('users')
|
||||
async createUser(@Body() dto: CreateUserDto) {
|
||||
return this.adminService.createUser(dto);
|
||||
}
|
||||
|
||||
@Put('users/:id')
|
||||
async updateUser(@Param('id') id: string, @Body() dto: UpdateUserDto) {
|
||||
return this.adminService.updateUser(id, dto);
|
||||
}
|
||||
|
||||
@Delete('users/:id')
|
||||
async deleteUser(@Param('id') id: string) {
|
||||
await this.adminService.deleteUser(id);
|
||||
return { deleted: true };
|
||||
}
|
||||
|
||||
@Get('clients')
|
||||
async listClients(
|
||||
@Query('page') page?: string,
|
||||
@Query('limit') limit?: string,
|
||||
) {
|
||||
return this.adminService.listClients(
|
||||
page ? parseInt(page, 10) : 1,
|
||||
limit ? parseInt(limit, 10) : 20,
|
||||
);
|
||||
}
|
||||
|
||||
@Get('clients/:id')
|
||||
async getClient(@Param('id') id: string) {
|
||||
return this.adminService.getClient(id);
|
||||
}
|
||||
|
||||
@Post('clients')
|
||||
async createClient(@Body() dto: CreateClientDto) {
|
||||
return this.adminService.createClient(dto);
|
||||
}
|
||||
|
||||
@Put('clients/:id')
|
||||
async updateClient(@Param('id') id: string, @Body() dto: UpdateClientDto) {
|
||||
return this.adminService.updateClient(id, dto);
|
||||
}
|
||||
|
||||
@Delete('clients/:id')
|
||||
async deleteClient(@Param('id') id: string) {
|
||||
await this.adminService.deleteClient(id);
|
||||
return { deleted: true };
|
||||
}
|
||||
}
|
||||
9
src/admin/admin.module.ts
Normal file
9
src/admin/admin.module.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AdminController } from './admin.controller';
|
||||
import { AdminService } from './admin.service';
|
||||
|
||||
@Module({
|
||||
controllers: [AdminController],
|
||||
providers: [AdminService],
|
||||
})
|
||||
export class AdminModule {}
|
||||
186
src/admin/admin.service.ts
Normal file
186
src/admin/admin.service.ts
Normal file
@@ -0,0 +1,186 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import * as argon2 from 'argon2';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { PinoLogger } from 'nestjs-pino';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { CreateUserDto, UpdateUserDto } from './dto/create-user.dto';
|
||||
import { CreateClientDto, UpdateClientDto } from './dto/create-client.dto';
|
||||
|
||||
@Injectable()
|
||||
export class AdminService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly logger: PinoLogger,
|
||||
) {
|
||||
this.logger.setContext(AdminService.name);
|
||||
}
|
||||
|
||||
async listUsers(page = 1, limit = 20) {
|
||||
const skip = (page - 1) * limit;
|
||||
const [users, total] = await Promise.all([
|
||||
this.prisma.user.findMany({
|
||||
skip,
|
||||
take: limit,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
phone: true,
|
||||
displayName: true,
|
||||
role: true,
|
||||
isActive: true,
|
||||
emailVerifiedAt: true,
|
||||
createdAt: true,
|
||||
_count: { select: { sessions: true } },
|
||||
},
|
||||
}),
|
||||
this.prisma.user.count(),
|
||||
]);
|
||||
return { users, total, page, limit, totalPages: Math.ceil(total / limit) };
|
||||
}
|
||||
|
||||
async getUser(id: string) {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id },
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
phone: true,
|
||||
displayName: true,
|
||||
avatarUrl: true,
|
||||
role: true,
|
||||
isActive: true,
|
||||
emailVerifiedAt: true,
|
||||
phoneVerifiedAt: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
_count: { select: { sessions: true, auditLogs: true } },
|
||||
},
|
||||
});
|
||||
if (!user) throw new NotFoundException('User not found');
|
||||
return user;
|
||||
}
|
||||
|
||||
async createUser(dto: CreateUserDto) {
|
||||
const passwordHash = await argon2.hash(dto.password);
|
||||
return this.prisma.user.create({
|
||||
data: {
|
||||
email: dto.email,
|
||||
passwordHash,
|
||||
displayName: dto.displayName,
|
||||
phone: dto.phone,
|
||||
role: dto.role ?? 'USER',
|
||||
},
|
||||
select: { id: true, email: true, displayName: true, role: true },
|
||||
});
|
||||
}
|
||||
|
||||
async updateUser(id: string, dto: UpdateUserDto) {
|
||||
const user = await this.prisma.user.findUnique({ where: { id } });
|
||||
if (!user) throw new NotFoundException('User not found');
|
||||
|
||||
const data: Record<string, unknown> = {};
|
||||
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);
|
||||
|
||||
return this.prisma.user.update({
|
||||
where: { id },
|
||||
data,
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
displayName: true,
|
||||
role: true,
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async deleteUser(id: 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 } });
|
||||
}
|
||||
|
||||
async listClients(page = 1, limit = 20) {
|
||||
const skip = (page - 1) * limit;
|
||||
const [clients, total] = await Promise.all([
|
||||
this.prisma.client.findMany({
|
||||
skip,
|
||||
take: limit,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: { owner: { select: { id: true, email: true } } },
|
||||
}),
|
||||
this.prisma.client.count(),
|
||||
]);
|
||||
return {
|
||||
clients,
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
};
|
||||
}
|
||||
|
||||
async getClient(id: string) {
|
||||
const client = await this.prisma.client.findUnique({
|
||||
where: { id },
|
||||
include: { owner: { select: { id: true, email: true } } },
|
||||
});
|
||||
if (!client) throw new NotFoundException('Client not found');
|
||||
return client;
|
||||
}
|
||||
|
||||
async createClient(dto: CreateClientDto, ownerId?: string) {
|
||||
const clientSecret = dto.clientSecret ?? randomBytes(32).toString('hex');
|
||||
return this.prisma.client.create({
|
||||
data: {
|
||||
name: dto.name,
|
||||
description: dto.description,
|
||||
clientId: dto.clientId,
|
||||
clientSecret,
|
||||
redirectUris: dto.redirectUris,
|
||||
grants: dto.grants,
|
||||
isConfidential: dto.isConfidential ?? true,
|
||||
logoUrl: dto.logoUrl,
|
||||
ownerId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async updateClient(id: string, dto: UpdateClientDto) {
|
||||
const client = await this.prisma.client.findUnique({ where: { id } });
|
||||
if (!client) throw new NotFoundException('Client not found');
|
||||
|
||||
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)
|
||||
data.isConfidential = dto.isConfidential;
|
||||
if (dto.logoUrl !== undefined) data.logoUrl = dto.logoUrl;
|
||||
|
||||
return this.prisma.client.update({ where: { id }, data });
|
||||
}
|
||||
|
||||
async deleteClient(id: string) {
|
||||
const client = await this.prisma.client.findUnique({ where: { id } });
|
||||
if (!client) throw new NotFoundException('Client not found');
|
||||
await this.prisma.client.delete({ where: { id } });
|
||||
}
|
||||
|
||||
async getStats() {
|
||||
const [users, clients, activeSessions] = await Promise.all([
|
||||
this.prisma.user.count(),
|
||||
this.prisma.client.count(),
|
||||
this.prisma.session.count({ where: { isActive: true } }),
|
||||
]);
|
||||
return { users, clients, activeSessions };
|
||||
}
|
||||
}
|
||||
61
src/admin/dto/create-client.dto.ts
Normal file
61
src/admin/dto/create-client.dto.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { IsString, IsOptional, IsArray, IsBoolean } from 'class-validator';
|
||||
|
||||
export class CreateClientDto {
|
||||
@IsString()
|
||||
name!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
description?: string;
|
||||
|
||||
@IsString()
|
||||
clientId!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
clientSecret?: string;
|
||||
|
||||
@IsArray()
|
||||
redirectUris!: string[];
|
||||
|
||||
@IsArray()
|
||||
grants!: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isConfidential?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
logoUrl?: string;
|
||||
}
|
||||
|
||||
export class UpdateClientDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
name?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
description?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
clientSecret?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
redirectUris?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
grants?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isConfidential?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
logoUrl?: string;
|
||||
}
|
||||
55
src/admin/dto/create-user.dto.ts
Normal file
55
src/admin/dto/create-user.dto.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import {
|
||||
IsEmail,
|
||||
IsString,
|
||||
MinLength,
|
||||
IsOptional,
|
||||
IsEnum,
|
||||
} from 'class-validator';
|
||||
import { Role } from '@prisma/client';
|
||||
|
||||
export class CreateUserDto {
|
||||
@IsEmail()
|
||||
email!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(8)
|
||||
password!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
displayName?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
phone?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(Role)
|
||||
role?: Role;
|
||||
}
|
||||
|
||||
export class UpdateUserDto {
|
||||
@IsOptional()
|
||||
@IsEmail()
|
||||
email?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(8)
|
||||
password?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
displayName?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
phone?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(Role)
|
||||
role?: Role;
|
||||
|
||||
@IsOptional()
|
||||
isActive?: boolean;
|
||||
}
|
||||
22
src/app.controller.spec.ts
Normal file
22
src/app.controller.spec.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { AppController } from './app.controller';
|
||||
import { AppService } from './app.service';
|
||||
|
||||
describe('AppController', () => {
|
||||
let appController: AppController;
|
||||
|
||||
beforeEach(async () => {
|
||||
const app: TestingModule = await Test.createTestingModule({
|
||||
controllers: [AppController],
|
||||
providers: [AppService],
|
||||
}).compile();
|
||||
|
||||
appController = app.get<AppController>(AppController);
|
||||
});
|
||||
|
||||
describe('root', () => {
|
||||
it('should return "Hello World!"', () => {
|
||||
expect(appController.getHello()).toBe('Hello World!');
|
||||
});
|
||||
});
|
||||
});
|
||||
12
src/app.controller.ts
Normal file
12
src/app.controller.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import { AppService } from './app.service';
|
||||
|
||||
@Controller()
|
||||
export class AppController {
|
||||
constructor(private readonly appService: AppService) {}
|
||||
|
||||
@Get()
|
||||
getHello(): string {
|
||||
return this.appService.getHello();
|
||||
}
|
||||
}
|
||||
82
src/app.module.ts
Normal file
82
src/app.module.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { ThrottlerModule } from '@nestjs/throttler';
|
||||
import { APP_FILTER, APP_GUARD } from '@nestjs/core';
|
||||
import { LoggerModule } from 'nestjs-pino';
|
||||
import { PrismaModule } from './prisma/prisma.module';
|
||||
import { AuthModule } from './auth/auth.module';
|
||||
import { OidcModule } from './oidc/oidc.module';
|
||||
import { EventsModule } from './events/events.module';
|
||||
import { MetricsModule } from './metrics/metrics.module';
|
||||
import { AdminModule } from './admin/admin.module';
|
||||
import { WebModule } from './web/web.module';
|
||||
import { MailModule } from './mail/mail.module';
|
||||
import { VerificationModule } from './verification/verification.module';
|
||||
import { QrAuthModule } from './qr-auth/qr-auth.module';
|
||||
import { AllExceptionsFilter } from './common/filters/all-exceptions.filter';
|
||||
import { JwtAuthGuard } from './auth/strategies/jwt-auth.guard';
|
||||
import { RolesGuard } from './common/guards/roles.guard';
|
||||
import { validateEnv } from './config/env.validation';
|
||||
import appConfig from './config/configuration';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
validate: validateEnv,
|
||||
load: [appConfig],
|
||||
}),
|
||||
LoggerModule.forRoot({
|
||||
pinoHttp: {
|
||||
autoLogging: {
|
||||
ignore: (req) => (req as { url?: string }).url === '/metrics',
|
||||
},
|
||||
serializers: {
|
||||
req: (req) => ({
|
||||
method: (req as { method?: string }).method,
|
||||
url: (req as { url?: string }).url,
|
||||
remoteAddress: (req as { remoteAddress?: string }).remoteAddress,
|
||||
}),
|
||||
res: (res) => ({
|
||||
statusCode: (res as { statusCode?: number }).statusCode,
|
||||
}),
|
||||
},
|
||||
redact: {
|
||||
paths: [
|
||||
'req.headers.authorization',
|
||||
'req.body.password',
|
||||
'req.body.refreshToken',
|
||||
'req.body.client_secret',
|
||||
],
|
||||
censor: '[REDACTED]',
|
||||
},
|
||||
transport:
|
||||
process.env.NODE_ENV !== 'production'
|
||||
? { target: 'pino-pretty', options: { colorize: true } }
|
||||
: undefined,
|
||||
},
|
||||
}),
|
||||
ThrottlerModule.forRoot([
|
||||
{
|
||||
ttl: parseInt(process.env.THROTTLE_TTL ?? '60000', 10),
|
||||
limit: parseInt(process.env.THROTTLE_LIMIT ?? '10', 10),
|
||||
},
|
||||
]),
|
||||
PrismaModule,
|
||||
EventsModule,
|
||||
MailModule,
|
||||
VerificationModule,
|
||||
QrAuthModule,
|
||||
AuthModule,
|
||||
OidcModule,
|
||||
MetricsModule,
|
||||
AdminModule,
|
||||
WebModule,
|
||||
],
|
||||
providers: [
|
||||
{ provide: APP_FILTER, useClass: AllExceptionsFilter },
|
||||
{ provide: APP_GUARD, useClass: JwtAuthGuard },
|
||||
{ provide: APP_GUARD, useClass: RolesGuard },
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
8
src/app.service.ts
Normal file
8
src/app.service.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
@Injectable()
|
||||
export class AppService {
|
||||
getHello(): string {
|
||||
return 'Hello World!';
|
||||
}
|
||||
}
|
||||
140
src/auth/auth.controller.ts
Normal file
140
src/auth/auth.controller.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import {
|
||||
Controller,
|
||||
Post,
|
||||
Get,
|
||||
Body,
|
||||
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';
|
||||
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
constructor(private readonly authService: AuthService) {}
|
||||
|
||||
@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']);
|
||||
}
|
||||
|
||||
@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']);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post('login/email-code')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async loginByEmailCode(
|
||||
@Body() body: { email: string; code: string },
|
||||
@Req() req: Request,
|
||||
) {
|
||||
return this.authService.loginByEmailCode(
|
||||
body.email,
|
||||
body.code,
|
||||
req.ip,
|
||||
req.headers['user-agent'],
|
||||
);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post('login/phone-code')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async loginByPhoneCode(
|
||||
@Body() body: { phone: string; code: string },
|
||||
@Req() req: Request,
|
||||
) {
|
||||
return this.authService.loginByPhoneCode(
|
||||
body.phone,
|
||||
body.code,
|
||||
req.ip,
|
||||
req.headers['user-agent'],
|
||||
);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post('send-email-code')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async sendEmailCode(@Body() body: { email: string }) {
|
||||
await this.authService.sendEmailCode(body.email);
|
||||
return { sent: true };
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post('send-phone-code')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async sendPhoneCode(@Body() body: { phone: string }) {
|
||||
await this.authService.sendPhoneCode(body.phone);
|
||||
return { sent: true };
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post('qr/init')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async initQr() {
|
||||
return this.authService.initQrSession();
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post('qr/poll')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async pollQr(@Body() body: { sessionId: string }) {
|
||||
return this.authService.pollQrSession(body.sessionId);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('refresh')
|
||||
async refresh(
|
||||
@Body('refreshToken') refreshToken: string,
|
||||
@Req() req: Request,
|
||||
) {
|
||||
return this.authService.refresh(
|
||||
refreshToken,
|
||||
req.ip,
|
||||
req.headers['user-agent'],
|
||||
);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@Post('logout')
|
||||
async logout(@Body('refreshToken') refreshToken: string) {
|
||||
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,
|
||||
) {
|
||||
const user = (req as RequestWithUser).user;
|
||||
return this.authService.updateProfile(user.sub, body);
|
||||
}
|
||||
}
|
||||
28
src/auth/auth.module.ts
Normal file
28
src/auth/auth.module.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { PassportModule } from '@nestjs/passport';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { AuthService } from './auth.service';
|
||||
import { JwtStrategy } from './strategies/jwt.strategy';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
PassportModule.register({ defaultStrategy: 'jwt' }),
|
||||
JwtModule.registerAsync({
|
||||
imports: [ConfigModule],
|
||||
inject: [ConfigService],
|
||||
useFactory: (config: ConfigService) => ({
|
||||
secret: config.get<string>('app.jwt.accessSecret'),
|
||||
signOptions: {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
expiresIn: config.get<any>('app.jwt.accessExpiresIn'),
|
||||
},
|
||||
}),
|
||||
}),
|
||||
],
|
||||
controllers: [AuthController],
|
||||
providers: [AuthService, JwtStrategy],
|
||||
exports: [AuthService, JwtModule, PassportModule],
|
||||
})
|
||||
export class AuthModule {}
|
||||
358
src/auth/auth.service.ts
Normal file
358
src/auth/auth.service.ts
Normal file
@@ -0,0 +1,358 @@
|
||||
import {
|
||||
Injectable,
|
||||
ConflictException,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { JwtService, JwtSignOptions } from '@nestjs/jwt';
|
||||
import * as argon2 from 'argon2';
|
||||
import { createHash, randomBytes } from 'node:crypto';
|
||||
import { PinoLogger } from 'nestjs-pino';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { EventsService } from '../events/events.service';
|
||||
import { VerificationService } from '../verification/verification.service';
|
||||
import { QrAuthService } from '../qr-auth/qr-auth.service';
|
||||
import { RegisterDto } from './dto/register.dto';
|
||||
import { LoginDto } from './dto/login.dto';
|
||||
import { JwtPayload } from './strategies/jwt.strategy';
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly jwtService: JwtService,
|
||||
private readonly configService: ConfigService,
|
||||
private readonly eventsService: EventsService,
|
||||
private readonly verificationService: VerificationService,
|
||||
private readonly qrAuthService: QrAuthService,
|
||||
private readonly logger: PinoLogger,
|
||||
) {
|
||||
this.logger.setContext(AuthService.name);
|
||||
}
|
||||
|
||||
async register(dto: RegisterDto, ip?: string, userAgent?: string) {
|
||||
const existing = await this.prisma.user.findUnique({
|
||||
where: { email: dto.email },
|
||||
});
|
||||
if (existing) {
|
||||
throw new ConflictException('Email already registered');
|
||||
}
|
||||
|
||||
const passwordHash = await argon2.hash(dto.password);
|
||||
|
||||
const user = await this.prisma.user.create({
|
||||
data: {
|
||||
email: dto.email,
|
||||
passwordHash,
|
||||
displayName: dto.displayName,
|
||||
},
|
||||
});
|
||||
|
||||
await this.prisma.auditLog.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
action: 'USER_REGISTERED',
|
||||
ip,
|
||||
userAgent,
|
||||
},
|
||||
});
|
||||
|
||||
this.eventsService.emit('auth.user.registered', {
|
||||
userId: user.id,
|
||||
email: user.email,
|
||||
});
|
||||
|
||||
this.logger.info({ userId: user.id }, 'User registered');
|
||||
|
||||
const tokens = await this.createSession(user.id, undefined, ip, userAgent);
|
||||
|
||||
return {
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
displayName: user.displayName,
|
||||
role: user.role,
|
||||
},
|
||||
...tokens,
|
||||
};
|
||||
}
|
||||
|
||||
async login(dto: LoginDto, ip?: string, userAgent?: string) {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { email: dto.email },
|
||||
});
|
||||
if (!user || !user.isActive || !user.passwordHash) {
|
||||
throw new UnauthorizedException('Invalid credentials');
|
||||
}
|
||||
|
||||
const valid = await argon2.verify(user.passwordHash, dto.password);
|
||||
if (!valid) {
|
||||
throw new UnauthorizedException('Invalid credentials');
|
||||
}
|
||||
|
||||
await this.prisma.auditLog.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
action: 'USER_LOGIN',
|
||||
ip,
|
||||
userAgent,
|
||||
},
|
||||
});
|
||||
|
||||
this.eventsService.emit('auth.user.logged_in', {
|
||||
userId: user.id,
|
||||
email: user.email,
|
||||
});
|
||||
|
||||
this.logger.info({ userId: user.id }, 'User logged in');
|
||||
|
||||
return this.createSession(user.id, undefined, ip, userAgent);
|
||||
}
|
||||
|
||||
async loginByEmailCode(
|
||||
email: string,
|
||||
code: string,
|
||||
ip?: string,
|
||||
userAgent?: string,
|
||||
) {
|
||||
await this.verificationService.verifyCode(email, code);
|
||||
|
||||
const user = await this.prisma.user.findUnique({ where: { email } });
|
||||
if (!user || !user.isActive) {
|
||||
throw new UnauthorizedException('User not found or deactivated');
|
||||
}
|
||||
|
||||
if (!user.emailVerifiedAt) {
|
||||
await this.prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { emailVerifiedAt: new Date() },
|
||||
});
|
||||
}
|
||||
|
||||
await this.prisma.auditLog.create({
|
||||
data: { userId: user.id, action: 'USER_LOGIN_EMAIL_CODE', ip, userAgent },
|
||||
});
|
||||
|
||||
return this.createSession(user.id, undefined, ip, userAgent);
|
||||
}
|
||||
|
||||
async loginByPhoneCode(
|
||||
phone: string,
|
||||
code: string,
|
||||
ip?: string,
|
||||
userAgent?: string,
|
||||
) {
|
||||
await this.verificationService.verifyCode(phone, code);
|
||||
|
||||
const user = await this.prisma.user.findUnique({ where: { phone } });
|
||||
if (!user || !user.isActive) {
|
||||
throw new UnauthorizedException('User not found or deactivated');
|
||||
}
|
||||
|
||||
if (!user.phoneVerifiedAt) {
|
||||
await this.prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { phoneVerifiedAt: new Date() },
|
||||
});
|
||||
}
|
||||
|
||||
await this.prisma.auditLog.create({
|
||||
data: { userId: user.id, action: 'USER_LOGIN_PHONE_CODE', ip, userAgent },
|
||||
});
|
||||
|
||||
return this.createSession(user.id, undefined, ip, userAgent);
|
||||
}
|
||||
|
||||
async sendEmailCode(email: string): Promise<void> {
|
||||
await this.verificationService.sendEmailCode(email);
|
||||
}
|
||||
|
||||
async sendPhoneCode(phone: string): Promise<void> {
|
||||
await this.verificationService.sendPhoneCode(phone);
|
||||
}
|
||||
|
||||
async initQrSession() {
|
||||
return this.qrAuthService.initSession();
|
||||
}
|
||||
|
||||
async pollQrSession(sessionId: string) {
|
||||
return this.qrAuthService.pollSession(sessionId);
|
||||
}
|
||||
|
||||
async refresh(refreshToken: string, ip?: string, userAgent?: string) {
|
||||
const tokenHash = this.hashToken(refreshToken);
|
||||
|
||||
const stored = await this.prisma.refreshToken.findUnique({
|
||||
where: { tokenHash },
|
||||
include: { session: true },
|
||||
});
|
||||
|
||||
if (!stored || stored.revoked || stored.expiresAt < new Date()) {
|
||||
throw new UnauthorizedException('Invalid or expired refresh token');
|
||||
}
|
||||
|
||||
if (!stored.session.isActive || stored.session.expiresAt < new Date()) {
|
||||
await this.prisma.refreshToken.update({
|
||||
where: { id: stored.id },
|
||||
data: { revoked: true },
|
||||
});
|
||||
throw new UnauthorizedException('Session expired');
|
||||
}
|
||||
|
||||
await this.prisma.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,
|
||||
);
|
||||
}
|
||||
|
||||
async logout(refreshToken: string) {
|
||||
const tokenHash = this.hashToken(refreshToken);
|
||||
const stored = await this.prisma.refreshToken.findUnique({
|
||||
where: { tokenHash },
|
||||
include: { session: true },
|
||||
});
|
||||
|
||||
if (stored) {
|
||||
await this.prisma.refreshToken.update({
|
||||
where: { id: stored.id },
|
||||
data: { revoked: true },
|
||||
});
|
||||
|
||||
if (stored.session) {
|
||||
await this.prisma.session.update({
|
||||
where: { id: stored.session.id },
|
||||
data: { isActive: false },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async getProfile(userId: string) {
|
||||
return this.prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
phone: true,
|
||||
displayName: true,
|
||||
avatarUrl: true,
|
||||
role: true,
|
||||
emailVerifiedAt: true,
|
||||
phoneVerifiedAt: true,
|
||||
createdAt: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async updateProfile(
|
||||
userId: string,
|
||||
data: { displayName?: string; avatarUrl?: string; phone?: string },
|
||||
) {
|
||||
return this.prisma.user.update({
|
||||
where: { id: userId },
|
||||
data,
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
phone: true,
|
||||
displayName: true,
|
||||
avatarUrl: true,
|
||||
role: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async createSession(
|
||||
userId: string,
|
||||
clientId?: string,
|
||||
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({
|
||||
data: {
|
||||
tokenHash: refreshTokenHash,
|
||||
sessionId: session.id,
|
||||
expiresAt: new Date(now.getTime() + refreshMs),
|
||||
},
|
||||
});
|
||||
|
||||
return { accessToken, refreshToken, sessionId: session.id };
|
||||
}
|
||||
|
||||
private async generateAccessToken(userId: string): Promise<string> {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { email: true, role: true },
|
||||
});
|
||||
|
||||
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'),
|
||||
};
|
||||
return this.jwtService.signAsync(
|
||||
{
|
||||
sub: userId,
|
||||
email: user?.email ?? '',
|
||||
role: user?.role ?? 'USER',
|
||||
} satisfies JwtPayload,
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
private generateRefreshToken(): string {
|
||||
return randomBytes(64).toString('hex');
|
||||
}
|
||||
|
||||
private hashToken(token: string): string {
|
||||
return createHash('sha256').update(token).digest('hex');
|
||||
}
|
||||
|
||||
private parseDuration(duration: string): number {
|
||||
const match = duration.match(/^(\d+)([smhd])$/);
|
||||
if (!match) return 7 * 24 * 60 * 60 * 1000;
|
||||
const val = parseInt(match[1], 10);
|
||||
switch (match[2]) {
|
||||
case 's':
|
||||
return val * 1000;
|
||||
case 'm':
|
||||
return val * 60 * 1000;
|
||||
case 'h':
|
||||
return val * 60 * 60 * 1000;
|
||||
case 'd':
|
||||
return val * 24 * 60 * 60 * 1000;
|
||||
default:
|
||||
return 7 * 24 * 60 * 60 * 1000;
|
||||
}
|
||||
}
|
||||
}
|
||||
13
src/auth/dto/login.dto.ts
Normal file
13
src/auth/dto/login.dto.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { IsEmail, IsString, IsOptional } from 'class-validator';
|
||||
|
||||
export class LoginDto {
|
||||
@IsEmail()
|
||||
email!: string;
|
||||
|
||||
@IsString()
|
||||
password!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
clientId?: string;
|
||||
}
|
||||
23
src/auth/dto/register.dto.ts
Normal file
23
src/auth/dto/register.dto.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import {
|
||||
IsEmail,
|
||||
IsString,
|
||||
MinLength,
|
||||
MaxLength,
|
||||
IsOptional,
|
||||
} from 'class-validator';
|
||||
|
||||
export class RegisterDto {
|
||||
@IsEmail()
|
||||
email!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(8)
|
||||
@MaxLength(128)
|
||||
password!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(255)
|
||||
displayName?: string;
|
||||
}
|
||||
20
src/auth/strategies/jwt-auth.guard.ts
Normal file
20
src/auth/strategies/jwt-auth.guard.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { Injectable, ExecutionContext } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { IS_PUBLIC_KEY } from '../../common/decorators/public.decorator';
|
||||
|
||||
@Injectable()
|
||||
export class JwtAuthGuard extends AuthGuard('jwt') {
|
||||
constructor(private readonly reflector: Reflector) {
|
||||
super();
|
||||
}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean | Promise<boolean> {
|
||||
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
if (isPublic) return true;
|
||||
return super.canActivate(context) as boolean | Promise<boolean>;
|
||||
}
|
||||
}
|
||||
40
src/auth/strategies/jwt.strategy.ts
Normal file
40
src/auth/strategies/jwt.strategy.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
|
||||
export interface JwtPayload {
|
||||
sub: string;
|
||||
email: string;
|
||||
role: string;
|
||||
iat?: number;
|
||||
exp?: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||
constructor(
|
||||
configService: ConfigService,
|
||||
private readonly prisma: PrismaService,
|
||||
) {
|
||||
super({
|
||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
ignoreExpiration: false,
|
||||
secretOrKey: configService.get<string>('app.jwt.accessSecret')!,
|
||||
});
|
||||
}
|
||||
|
||||
async validate(payload: JwtPayload): Promise<JwtPayload> {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id: payload.sub },
|
||||
select: { id: true, isActive: true },
|
||||
});
|
||||
|
||||
if (!user || !user.isActive) {
|
||||
throw new UnauthorizedException('User is deactivated or not found');
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
}
|
||||
4
src/common/decorators/public.decorator.ts
Normal file
4
src/common/decorators/public.decorator.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
|
||||
export const IS_PUBLIC_KEY = 'isPublic';
|
||||
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
|
||||
5
src/common/decorators/roles.decorator.ts
Normal file
5
src/common/decorators/roles.decorator.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
import { Role } from '@prisma/client';
|
||||
|
||||
export const ROLES_KEY = 'roles';
|
||||
export const Roles = (...roles: Role[]) => SetMetadata(ROLES_KEY, roles);
|
||||
54
src/common/filters/all-exceptions.filter.ts
Normal file
54
src/common/filters/all-exceptions.filter.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import {
|
||||
ExceptionFilter,
|
||||
Catch,
|
||||
ArgumentsHost,
|
||||
HttpException,
|
||||
HttpStatus,
|
||||
} from '@nestjs/common';
|
||||
import { PinoLogger } from 'nestjs-pino';
|
||||
import type { Response, Request } from 'express';
|
||||
|
||||
@Catch()
|
||||
export class AllExceptionsFilter implements ExceptionFilter {
|
||||
constructor(private readonly logger: PinoLogger) {
|
||||
this.logger.setContext(AllExceptionsFilter.name);
|
||||
}
|
||||
|
||||
catch(exception: unknown, host: ArgumentsHost): void {
|
||||
const ctx = host.switchToHttp();
|
||||
const response = ctx.getResponse<Response>();
|
||||
const request = ctx.getRequest<Request>();
|
||||
|
||||
let status = HttpStatus.INTERNAL_SERVER_ERROR;
|
||||
let message = 'Internal server error';
|
||||
|
||||
if (exception instanceof HttpException) {
|
||||
status = exception.getStatus();
|
||||
const res = exception.getResponse();
|
||||
message =
|
||||
typeof res === 'string'
|
||||
? res
|
||||
: (((res as Record<string, unknown>)?.message as string) ??
|
||||
exception.message);
|
||||
} else if (exception instanceof Error) {
|
||||
message = exception.message;
|
||||
}
|
||||
|
||||
if (status === HttpStatus.INTERNAL_SERVER_ERROR) {
|
||||
this.logger.error(
|
||||
{
|
||||
err: exception instanceof Error ? exception : undefined,
|
||||
path: request.url,
|
||||
},
|
||||
message,
|
||||
);
|
||||
}
|
||||
|
||||
response.status(status).json({
|
||||
statusCode: status,
|
||||
message: Array.isArray(message) ? message : [message],
|
||||
timestamp: new Date().toISOString(),
|
||||
path: request.url,
|
||||
});
|
||||
}
|
||||
}
|
||||
24
src/common/guards/roles.guard.ts
Normal file
24
src/common/guards/roles.guard.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { Role } from '@prisma/client';
|
||||
import { ROLES_KEY } from '../decorators/roles.decorator';
|
||||
import { JwtPayload } from '../../auth/strategies/jwt.strategy';
|
||||
|
||||
@Injectable()
|
||||
export class RolesGuard implements CanActivate {
|
||||
constructor(private readonly reflector: Reflector) {}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const requiredRoles = this.reflector.getAllAndOverride<Role[]>(ROLES_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
if (!requiredRoles || requiredRoles.length === 0) return true;
|
||||
|
||||
const request = context.switchToHttp().getRequest<{ user?: JwtPayload }>();
|
||||
const user = request.user;
|
||||
if (!user) return false;
|
||||
|
||||
return requiredRoles.includes(user.role as Role);
|
||||
}
|
||||
}
|
||||
6
src/common/types.ts
Normal file
6
src/common/types.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import type { Request } from 'express';
|
||||
import type { JwtPayload } from '../auth/strategies/jwt.strategy';
|
||||
|
||||
export interface RequestWithUser extends Request {
|
||||
user: JwtPayload;
|
||||
}
|
||||
33
src/config/configuration.ts
Normal file
33
src/config/configuration.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { registerAs } from '@nestjs/config';
|
||||
|
||||
export default registerAs('app', () => ({
|
||||
nodeEnv: process.env.NODE_ENV,
|
||||
port: parseInt(process.env.PORT ?? '3000', 10),
|
||||
host: process.env.HOST ?? '0.0.0.0',
|
||||
trustProxy: parseInt(process.env.TRUST_PROXY ?? '1', 10),
|
||||
|
||||
jwt: {
|
||||
accessSecret: process.env.JWT_ACCESS_SECRET!,
|
||||
refreshSecret: process.env.JWT_REFRESH_SECRET!,
|
||||
accessExpiresIn: process.env.JWT_ACCESS_EXPIRES_IN ?? '15m',
|
||||
refreshExpiresIn: process.env.JWT_REFRESH_EXPIRES_IN ?? '7d',
|
||||
},
|
||||
|
||||
redis: {
|
||||
host: process.env.REDIS_HOST ?? 'localhost',
|
||||
port: parseInt(process.env.REDIS_PORT ?? '6379', 10),
|
||||
},
|
||||
|
||||
rabbitmq: {
|
||||
url: process.env.RABBITMQ_URL!,
|
||||
},
|
||||
|
||||
cors: {
|
||||
origins: (process.env.CORS_ORIGINS ?? 'http://localhost:3000').split(','),
|
||||
},
|
||||
|
||||
throttle: {
|
||||
ttl: parseInt(process.env.THROTTLE_TTL ?? '60', 10),
|
||||
limit: parseInt(process.env.THROTTLE_LIMIT ?? '10', 10),
|
||||
},
|
||||
}));
|
||||
56
src/config/env.validation.ts
Normal file
56
src/config/env.validation.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const envSchema = z.object({
|
||||
NODE_ENV: z
|
||||
.enum(['development', 'production', 'test'])
|
||||
.default('development'),
|
||||
PORT: z.coerce.number().int().positive().default(3000),
|
||||
HOST: z.string().default('0.0.0.0'),
|
||||
TRUST_PROXY: z.coerce.number().int().min(0).default(1),
|
||||
|
||||
JWT_ACCESS_SECRET: z.string().min(32),
|
||||
JWT_REFRESH_SECRET: z.string().min(32),
|
||||
JWT_ACCESS_EXPIRES_IN: z.string().default('15m'),
|
||||
JWT_REFRESH_EXPIRES_IN: z.string().default('7d'),
|
||||
|
||||
DATABASE_URL: z.string().url(),
|
||||
POSTGRES_USER: z.string().min(1),
|
||||
POSTGRES_PASSWORD: z.string().min(1),
|
||||
POSTGRES_DB: z.string().min(1),
|
||||
|
||||
REDIS_HOST: z.string().default('localhost'),
|
||||
REDIS_PORT: z.coerce.number().int().positive().default(6379),
|
||||
|
||||
RABBITMQ_USER: z.string().min(1),
|
||||
RABBITMQ_PASSWORD: z.string().min(1),
|
||||
RABBITMQ_HOST: z.string().default('localhost'),
|
||||
RABBITMQ_PORT: z.coerce.number().int().positive().default(5672),
|
||||
RABBITMQ_URL: z.string(),
|
||||
|
||||
CORS_ORIGINS: z.string().default('http://localhost:3000'),
|
||||
|
||||
THROTTLE_TTL: z.coerce.number().int().positive().default(60),
|
||||
THROTTLE_LIMIT: z.coerce.number().int().positive().default(10),
|
||||
|
||||
SMTP_HOST: z.string().default('localhost'),
|
||||
SMTP_PORT: z.coerce.number().int().positive().default(1025),
|
||||
SMTP_USER: z.string().default(''),
|
||||
SMTP_PASSWORD: z.string().default(''),
|
||||
SMTP_FROM: z.string().default('noreply@sso.local'),
|
||||
|
||||
SMS_PROVIDER: z.string().default('mock'),
|
||||
});
|
||||
|
||||
export type EnvConfig = z.infer<typeof envSchema>;
|
||||
|
||||
export function validateEnv(config: Record<string, unknown>): EnvConfig {
|
||||
const result = envSchema.safeParse(config);
|
||||
if (!result.success) {
|
||||
const errors = result.error.flatten().fieldErrors;
|
||||
const messages = Object.entries(errors)
|
||||
.map(([key, msgs]) => `${key}: ${msgs?.join(', ')}`)
|
||||
.join('\n ');
|
||||
throw new Error(`Config validation failed:\n ${messages}`);
|
||||
}
|
||||
return result.data;
|
||||
}
|
||||
9
src/events/events.module.ts
Normal file
9
src/events/events.module.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { EventsService } from './events.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [EventsService],
|
||||
exports: [EventsService],
|
||||
})
|
||||
export class EventsModule {}
|
||||
35
src/events/events.service.ts
Normal file
35
src/events/events.service.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import {
|
||||
ClientProxy,
|
||||
ClientProxyFactory,
|
||||
Transport,
|
||||
} from '@nestjs/microservices';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { PinoLogger } from 'nestjs-pino';
|
||||
|
||||
@Injectable()
|
||||
export class EventsService {
|
||||
private client: ClientProxy;
|
||||
|
||||
constructor(
|
||||
private readonly configService: ConfigService,
|
||||
private readonly logger: PinoLogger,
|
||||
) {
|
||||
this.logger.setContext(EventsService.name);
|
||||
this.client = ClientProxyFactory.create({
|
||||
transport: Transport.RMQ,
|
||||
options: {
|
||||
urls: [this.configService.get<string>('app.rabbitmq.url')!],
|
||||
queue: 'sso_events',
|
||||
queueOptions: { durable: true },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
emit(pattern: string, data: Record<string, unknown>): void {
|
||||
this.client.emit(pattern, data).subscribe({
|
||||
error: (err: Error) =>
|
||||
this.logger.error({ err, pattern }, 'Failed to emit event'),
|
||||
});
|
||||
}
|
||||
}
|
||||
9
src/mail/mail.module.ts
Normal file
9
src/mail/mail.module.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { MailService } from './mail.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [MailService],
|
||||
exports: [MailService],
|
||||
})
|
||||
export class MailModule {}
|
||||
44
src/mail/mail.service.ts
Normal file
44
src/mail/mail.service.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import * as nodemailer from 'nodemailer';
|
||||
import { PinoLogger } from 'nestjs-pino';
|
||||
|
||||
@Injectable()
|
||||
export class MailService {
|
||||
private transporter: nodemailer.Transporter;
|
||||
|
||||
constructor(
|
||||
private readonly configService: ConfigService,
|
||||
private readonly logger: PinoLogger,
|
||||
) {
|
||||
this.logger.setContext(MailService.name);
|
||||
this.transporter = nodemailer.createTransport({
|
||||
host: this.configService.get<string>('SMTP_HOST'),
|
||||
port: this.configService.get<number>('SMTP_PORT'),
|
||||
secure: false,
|
||||
auth: this.configService.get<string>('SMTP_USER')
|
||||
? {
|
||||
user: this.configService.get<string>('SMTP_USER'),
|
||||
pass: this.configService.get<string>('SMTP_PASSWORD'),
|
||||
}
|
||||
: undefined,
|
||||
tls: { rejectUnauthorized: false },
|
||||
});
|
||||
}
|
||||
|
||||
async sendCode(to: string, code: string, purpose: string): Promise<void> {
|
||||
const from = this.configService.get<string>('SMTP_FROM')!;
|
||||
try {
|
||||
await this.transporter.sendMail({
|
||||
from,
|
||||
to,
|
||||
subject: `SSO: код ${purpose === 'AUTH' ? 'для входа' : 'подтверждения'}`,
|
||||
text: `Ваш код: ${code}\nДействителен 5 минут.`,
|
||||
html: `<p>Ваш код: <strong>${code}</strong></p><p>Действителен 5 минут.</p>`,
|
||||
});
|
||||
this.logger.info({ to, purpose }, 'Verification code sent via email');
|
||||
} catch (err: unknown) {
|
||||
this.logger.error({ err: err as Error, to }, 'Failed to send email');
|
||||
}
|
||||
}
|
||||
}
|
||||
71
src/main.ts
Normal file
71
src/main.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { ValidationPipe } from '@nestjs/common';
|
||||
import { Logger } from 'nestjs-pino';
|
||||
import { NestExpressApplication } from '@nestjs/platform-express';
|
||||
import * as helmet from 'helmet';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import { join } from 'node:path';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { AppModule } from './app.module';
|
||||
|
||||
async function bootstrap(): Promise<void> {
|
||||
const app = await NestFactory.create<NestExpressApplication>(AppModule, {
|
||||
bufferLogs: true,
|
||||
});
|
||||
|
||||
const logger = app.get(Logger);
|
||||
app.useLogger(logger);
|
||||
|
||||
app.use(helmet.default({ contentSecurityPolicy: false }));
|
||||
app.use(cookieParser());
|
||||
|
||||
app.enableCors({
|
||||
origin: (process.env.CORS_ORIGINS ?? 'http://localhost:3000').split(','),
|
||||
credentials: true,
|
||||
});
|
||||
|
||||
const viewsPath = join(process.cwd(), 'views');
|
||||
if (existsSync(viewsPath)) {
|
||||
app.setBaseViewsDir(viewsPath);
|
||||
app.setViewEngine('ejs');
|
||||
}
|
||||
|
||||
const publicPath = join(process.cwd(), 'public');
|
||||
if (existsSync(publicPath)) {
|
||||
app.useStaticAssets(publicPath);
|
||||
}
|
||||
|
||||
app.setGlobalPrefix('api', {
|
||||
exclude: [
|
||||
'/',
|
||||
'/login',
|
||||
'/register',
|
||||
'/profile',
|
||||
'/admin',
|
||||
'/admin/users',
|
||||
'/admin/clients',
|
||||
'/metrics',
|
||||
],
|
||||
});
|
||||
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
whitelist: true,
|
||||
forbidNonWhitelisted: true,
|
||||
transform: true,
|
||||
}),
|
||||
);
|
||||
|
||||
const trustProxy = parseInt(process.env.TRUST_PROXY ?? '1', 10);
|
||||
|
||||
const instance: any = app.getHttpAdapter().getInstance();
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
|
||||
instance.set('trust proxy', trustProxy);
|
||||
|
||||
const port = parseInt(process.env.PORT ?? '3000', 10);
|
||||
const host = process.env.HOST ?? '0.0.0.0';
|
||||
|
||||
await app.listen(port, host);
|
||||
logger.log(`SSO service listening on http://${host}:${port}`);
|
||||
}
|
||||
void bootstrap();
|
||||
33
src/metrics/metrics.module.ts
Normal file
33
src/metrics/metrics.module.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import {
|
||||
PrometheusModule,
|
||||
makeCounterProvider,
|
||||
makeGaugeProvider,
|
||||
} from '@willsoto/nestjs-prometheus';
|
||||
import { MetricsService } from './metrics.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
PrometheusModule.register({
|
||||
path: '/metrics',
|
||||
defaultMetrics: { enabled: true },
|
||||
}),
|
||||
],
|
||||
providers: [
|
||||
MetricsService,
|
||||
makeCounterProvider({
|
||||
name: 'sso_logins_total',
|
||||
help: 'Total number of successful logins',
|
||||
}),
|
||||
makeCounterProvider({
|
||||
name: 'sso_logins_failed_total',
|
||||
help: 'Total number of failed login attempts',
|
||||
}),
|
||||
makeGaugeProvider({
|
||||
name: 'sso_active_sessions',
|
||||
help: 'Number of active sessions',
|
||||
}),
|
||||
],
|
||||
exports: [MetricsService],
|
||||
})
|
||||
export class MetricsModule {}
|
||||
27
src/metrics/metrics.service.ts
Normal file
27
src/metrics/metrics.service.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectMetric } from '@willsoto/nestjs-prometheus';
|
||||
import { Counter, Gauge } from 'prom-client';
|
||||
|
||||
@Injectable()
|
||||
export class MetricsService {
|
||||
constructor(
|
||||
@InjectMetric('sso_logins_total')
|
||||
private readonly loginsCounter: Counter<string>,
|
||||
@InjectMetric('sso_logins_failed_total')
|
||||
private readonly failedLoginsCounter: Counter<string>,
|
||||
@InjectMetric('sso_active_sessions')
|
||||
private readonly activeSessionsGauge: Gauge<string>,
|
||||
) {}
|
||||
|
||||
incrementLogins(): void {
|
||||
this.loginsCounter.inc();
|
||||
}
|
||||
|
||||
incrementFailedLogins(): void {
|
||||
this.failedLoginsCounter.inc();
|
||||
}
|
||||
|
||||
setActiveSessions(count: number): void {
|
||||
this.activeSessionsGauge.set(count);
|
||||
}
|
||||
}
|
||||
28
src/oidc/dto/authorize.dto.ts
Normal file
28
src/oidc/dto/authorize.dto.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { IsString, IsOptional } from 'class-validator';
|
||||
|
||||
export class AuthorizeDto {
|
||||
@IsString()
|
||||
response_type!: string;
|
||||
|
||||
@IsString()
|
||||
client_id!: string;
|
||||
|
||||
@IsString()
|
||||
redirect_uri!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
scope?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
state?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
code_challenge?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
code_challenge_method?: string;
|
||||
}
|
||||
30
src/oidc/dto/token.dto.ts
Normal file
30
src/oidc/dto/token.dto.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { IsString, IsOptional } from 'class-validator';
|
||||
|
||||
export class TokenDto {
|
||||
@IsString()
|
||||
grant_type!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
code?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
refresh_token?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
client_id?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
client_secret?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
redirect_uri?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
code_verifier?: string;
|
||||
}
|
||||
84
src/oidc/oidc.controller.ts
Normal file
84
src/oidc/oidc.controller.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Body,
|
||||
Query,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Headers,
|
||||
Req,
|
||||
Res,
|
||||
} from '@nestjs/common';
|
||||
import type { Request, Response } from 'express';
|
||||
import { Public } from '../common/decorators/public.decorator';
|
||||
import { OidcService } from './oidc.service';
|
||||
|
||||
@Controller()
|
||||
export class OidcController {
|
||||
constructor(private readonly oidcService: OidcService) {}
|
||||
|
||||
@Public()
|
||||
@Get('authorize')
|
||||
async authorize(
|
||||
@Query('response_type') responseType: string,
|
||||
@Query('client_id') clientId: string,
|
||||
@Query('redirect_uri') redirectUri: string,
|
||||
@Query('scope') scope: string,
|
||||
@Query('state') state: string,
|
||||
@Query('code_challenge') codeChallenge: string,
|
||||
@Query('code_challenge_method') codeChallengeMethod: string,
|
||||
@Res() res: Response,
|
||||
) {
|
||||
if (responseType !== 'code') {
|
||||
return res.status(400).json({ error: 'unsupported_response_type' });
|
||||
}
|
||||
|
||||
const result = await this.oidcService.authorize(
|
||||
clientId,
|
||||
redirectUri,
|
||||
scope || 'openid profile',
|
||||
state,
|
||||
codeChallenge,
|
||||
codeChallengeMethod,
|
||||
);
|
||||
|
||||
return res.redirect(result.redirectUrl);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post('token')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async token(@Body() body: Record<string, string>) {
|
||||
return this.oidcService.token(
|
||||
body.grant_type,
|
||||
body.code,
|
||||
body.refresh_token,
|
||||
body.client_id,
|
||||
body.client_secret,
|
||||
body.redirect_uri,
|
||||
body.code_verifier,
|
||||
);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Get('userinfo')
|
||||
async userinfo(@Req() req: Request) {
|
||||
const authHeader = req.headers.authorization;
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
throw new (await import('@nestjs/common')).UnauthorizedException(
|
||||
'Missing token',
|
||||
);
|
||||
}
|
||||
const token = authHeader.slice(7);
|
||||
return this.oidcService.userinfo(token);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post('revoke')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async revoke(@Body('token') token: string) {
|
||||
await this.oidcService.revoke(token);
|
||||
return { revoked: true };
|
||||
}
|
||||
}
|
||||
26
src/oidc/oidc.module.ts
Normal file
26
src/oidc/oidc.module.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { OidcController } from './oidc.controller';
|
||||
import { OidcService } from './oidc.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
AuthModule,
|
||||
JwtModule.registerAsync({
|
||||
imports: [ConfigModule],
|
||||
inject: [ConfigService],
|
||||
useFactory: (config: ConfigService) => ({
|
||||
secret: config.get<string>('app.jwt.accessSecret'),
|
||||
signOptions: {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
expiresIn: config.get<any>('app.jwt.accessExpiresIn'),
|
||||
},
|
||||
}),
|
||||
}),
|
||||
],
|
||||
controllers: [OidcController],
|
||||
providers: [OidcService],
|
||||
})
|
||||
export class OidcModule {}
|
||||
206
src/oidc/oidc.service.ts
Normal file
206
src/oidc/oidc.service.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
import {
|
||||
Injectable,
|
||||
BadRequestException,
|
||||
UnauthorizedException,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { JwtService, JwtSignOptions } from '@nestjs/jwt';
|
||||
import { createHash, randomBytes } from 'node:crypto';
|
||||
import { PinoLogger } from 'nestjs-pino';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { AuthService } from '../auth/auth.service';
|
||||
|
||||
@Injectable()
|
||||
export class OidcService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly jwtService: JwtService,
|
||||
private readonly configService: ConfigService,
|
||||
private readonly authService: AuthService,
|
||||
private readonly logger: PinoLogger,
|
||||
) {
|
||||
this.logger.setContext(OidcService.name);
|
||||
}
|
||||
|
||||
async authorize(
|
||||
clientId: string,
|
||||
redirectUri: string,
|
||||
scope: string,
|
||||
state?: string,
|
||||
codeChallenge?: string,
|
||||
codeChallengeMethod?: string,
|
||||
) {
|
||||
const client = await this.prisma.client.findUnique({
|
||||
where: { clientId },
|
||||
});
|
||||
|
||||
if (!client) {
|
||||
throw new BadRequestException('Invalid client_id');
|
||||
}
|
||||
|
||||
if (!client.redirectUris.includes(redirectUri)) {
|
||||
throw new BadRequestException('Invalid redirect_uri');
|
||||
}
|
||||
|
||||
const authorizationCode = randomBytes(32).toString('hex');
|
||||
const codeHash = createHash('sha256')
|
||||
.update(authorizationCode)
|
||||
.digest('hex');
|
||||
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
||||
return { redirectUrl: redirectUrl.toString(), codeHash };
|
||||
}
|
||||
|
||||
async token(
|
||||
grantType: string,
|
||||
code?: string,
|
||||
refreshToken?: string,
|
||||
clientId?: string,
|
||||
clientSecret?: string,
|
||||
redirectUri?: string,
|
||||
codeVerifier?: string,
|
||||
) {
|
||||
switch (grantType) {
|
||||
case 'authorization_code':
|
||||
return this.tokenByAuthCode(
|
||||
code!,
|
||||
clientId!,
|
||||
clientSecret,
|
||||
redirectUri,
|
||||
codeVerifier,
|
||||
);
|
||||
case 'refresh_token':
|
||||
return this.authService.refresh(refreshToken!);
|
||||
case 'client_credentials':
|
||||
return this.tokenByClientCredentials(clientId!, clientSecret!);
|
||||
default:
|
||||
throw new BadRequestException('Unsupported grant_type');
|
||||
}
|
||||
}
|
||||
|
||||
async userinfo(accessToken: string) {
|
||||
try {
|
||||
const payload = await this.jwtService.verifyAsync<{ sub: string }>(
|
||||
accessToken,
|
||||
{
|
||||
secret: this.configService.get<string>('app.jwt.accessSecret'),
|
||||
},
|
||||
);
|
||||
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id: payload.sub },
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
displayName: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new NotFoundException('User not found');
|
||||
}
|
||||
|
||||
return {
|
||||
sub: user.id,
|
||||
email: user.email,
|
||||
name: user.displayName,
|
||||
};
|
||||
} catch {
|
||||
throw new UnauthorizedException('Invalid or expired access token');
|
||||
}
|
||||
}
|
||||
|
||||
async revoke(token: string) {
|
||||
await this.authService.logout(token);
|
||||
}
|
||||
|
||||
private async tokenByAuthCode(
|
||||
code: string,
|
||||
clientId: string,
|
||||
clientSecret?: string,
|
||||
redirectUri?: string,
|
||||
codeVerifier?: string,
|
||||
) {
|
||||
const client = await this.prisma.client.findUnique({
|
||||
where: { clientId },
|
||||
});
|
||||
|
||||
if (!client) {
|
||||
throw new BadRequestException('Invalid client_id');
|
||||
}
|
||||
|
||||
if (client.isConfidential) {
|
||||
if (!clientSecret || client.clientSecret !== clientSecret) {
|
||||
throw new UnauthorizedException('Invalid client_secret');
|
||||
}
|
||||
}
|
||||
|
||||
if (redirectUri && !client.redirectUris.includes(redirectUri)) {
|
||||
throw new BadRequestException('Invalid redirect_uri');
|
||||
}
|
||||
|
||||
if (codeVerifier) {
|
||||
void createHash('sha256').update(codeVerifier).digest('base64url');
|
||||
}
|
||||
|
||||
const refreshToken = randomBytes(64).toString('hex');
|
||||
|
||||
const options = {
|
||||
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;
|
||||
const accessToken = await this.jwtService.signAsync(
|
||||
{ sub: clientId, client_id: clientId },
|
||||
options,
|
||||
);
|
||||
|
||||
return {
|
||||
access_token: accessToken,
|
||||
token_type: 'Bearer',
|
||||
expires_in: 900,
|
||||
refresh_token: refreshToken,
|
||||
};
|
||||
}
|
||||
|
||||
private async tokenByClientCredentials(
|
||||
clientId: string,
|
||||
clientSecret: string,
|
||||
) {
|
||||
const client = await this.prisma.client.findUnique({
|
||||
where: { clientId },
|
||||
});
|
||||
|
||||
if (!client || client.clientSecret !== clientSecret) {
|
||||
throw new UnauthorizedException('Invalid client credentials');
|
||||
}
|
||||
|
||||
const options = {
|
||||
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;
|
||||
const accessToken = await this.jwtService.signAsync(
|
||||
{ sub: clientId, client_id: clientId },
|
||||
options,
|
||||
);
|
||||
|
||||
return {
|
||||
access_token: accessToken,
|
||||
token_type: 'Bearer',
|
||||
expires_in: 900,
|
||||
};
|
||||
}
|
||||
}
|
||||
9
src/prisma/prisma.module.ts
Normal file
9
src/prisma/prisma.module.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { PrismaService } from './prisma.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [PrismaService],
|
||||
exports: [PrismaService],
|
||||
})
|
||||
export class PrismaModule {}
|
||||
24
src/prisma/prisma.service.ts
Normal file
24
src/prisma/prisma.service.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { PrismaPg } from '@prisma/adapter-pg';
|
||||
import pg from 'pg';
|
||||
|
||||
@Injectable()
|
||||
export class PrismaService
|
||||
extends PrismaClient
|
||||
implements OnModuleInit, OnModuleDestroy
|
||||
{
|
||||
constructor() {
|
||||
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });
|
||||
const adapter = new PrismaPg(pool);
|
||||
super({ adapter });
|
||||
}
|
||||
|
||||
async onModuleInit(): Promise<void> {
|
||||
await this.$connect();
|
||||
}
|
||||
|
||||
async onModuleDestroy(): Promise<void> {
|
||||
await this.$disconnect();
|
||||
}
|
||||
}
|
||||
4
src/profile/profile.module.ts
Normal file
4
src/profile/profile.module.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
@Module({})
|
||||
export class ProfileModule {}
|
||||
8
src/qr-auth/qr-auth.module.ts
Normal file
8
src/qr-auth/qr-auth.module.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { QrAuthService } from './qr-auth.service';
|
||||
|
||||
@Module({
|
||||
providers: [QrAuthService],
|
||||
exports: [QrAuthService],
|
||||
})
|
||||
export class QrAuthModule {}
|
||||
145
src/qr-auth/qr-auth.service.ts
Normal file
145
src/qr-auth/qr-auth.service.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import * as qrcode from 'qrcode';
|
||||
import { PinoLogger } from 'nestjs-pino';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { AuthService } from '../auth/auth.service';
|
||||
import { JwtPayload } from '../auth/strategies/jwt.strategy';
|
||||
|
||||
@Injectable()
|
||||
export class QrAuthService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly jwtService: JwtService,
|
||||
private readonly configService: ConfigService,
|
||||
private readonly authService: AuthService,
|
||||
private readonly logger: PinoLogger,
|
||||
) {
|
||||
this.logger.setContext(QrAuthService.name);
|
||||
}
|
||||
|
||||
async initSession(): Promise<{
|
||||
sessionId: string;
|
||||
qrDataUrl: string;
|
||||
qrToken: string;
|
||||
}> {
|
||||
const qrSessionId = uuid();
|
||||
const qrToken = uuid();
|
||||
|
||||
const qrSession = await this.prisma.qrSession.create({
|
||||
data: {
|
||||
sessionId: qrSessionId,
|
||||
qrData: qrToken,
|
||||
expiresAt: new Date(Date.now() + 2 * 60 * 1000),
|
||||
},
|
||||
});
|
||||
|
||||
const qrPayload = JSON.stringify({
|
||||
type: 'sso_qr',
|
||||
sessionId: qrSessionId,
|
||||
token: qrToken,
|
||||
server: this.configService.get<string>('HOST')!,
|
||||
});
|
||||
|
||||
const qrDataUrl = await qrcode.toDataURL(qrPayload);
|
||||
|
||||
return { sessionId: qrSession.sessionId, qrDataUrl, qrToken };
|
||||
}
|
||||
|
||||
async scanSession(
|
||||
sessionId: string,
|
||||
qrToken: string,
|
||||
userPayload: JwtPayload,
|
||||
): Promise<void> {
|
||||
const session = await this.prisma.qrSession.findUnique({
|
||||
where: { sessionId },
|
||||
});
|
||||
|
||||
if (
|
||||
!session ||
|
||||
session.status !== 'PENDING' ||
|
||||
session.expiresAt < new Date()
|
||||
) {
|
||||
throw new UnauthorizedException('Invalid or expired QR session');
|
||||
}
|
||||
|
||||
if (session.qrData !== qrToken) {
|
||||
throw new UnauthorizedException('QR token mismatch');
|
||||
}
|
||||
|
||||
await this.prisma.qrSession.update({
|
||||
where: { id: session.id },
|
||||
data: {
|
||||
userId: userPayload.sub,
|
||||
status: 'SCANNED',
|
||||
deviceInfo: 'Mobile app',
|
||||
},
|
||||
});
|
||||
|
||||
this.logger.info(
|
||||
{ sessionId, userId: userPayload.sub },
|
||||
'QR session scanned',
|
||||
);
|
||||
}
|
||||
|
||||
async confirmSession(sessionId: string, userId: string): Promise<void> {
|
||||
const session = await this.prisma.qrSession.findUnique({
|
||||
where: { sessionId },
|
||||
});
|
||||
|
||||
if (!session || session.userId !== userId || session.status !== 'SCANNED') {
|
||||
throw new UnauthorizedException('Cannot confirm QR session');
|
||||
}
|
||||
|
||||
await this.prisma.qrSession.update({
|
||||
where: { id: session.id },
|
||||
data: { status: 'CONFIRMED', confirmedAt: new Date() },
|
||||
});
|
||||
|
||||
this.logger.info({ sessionId, userId }, 'QR session confirmed');
|
||||
}
|
||||
|
||||
async pollSession(
|
||||
sessionId: string,
|
||||
): Promise<{ accessToken?: string; status: string }> {
|
||||
const session = await this.prisma.qrSession.findUnique({
|
||||
where: { sessionId },
|
||||
});
|
||||
|
||||
if (!session) {
|
||||
return { status: 'EXPIRED' };
|
||||
}
|
||||
|
||||
if (session.status === 'CONFIRMED' && session.userId) {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id: session.userId },
|
||||
select: { id: true, email: true, role: true },
|
||||
});
|
||||
if (!user) return { status: 'ERROR' };
|
||||
|
||||
const 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 },
|
||||
data: { status: 'EXPIRED' },
|
||||
});
|
||||
|
||||
return { accessToken, status: 'CONFIRMED' };
|
||||
}
|
||||
|
||||
return { status: session.status };
|
||||
}
|
||||
}
|
||||
9
src/verification/verification.module.ts
Normal file
9
src/verification/verification.module.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { VerificationService } from './verification.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [VerificationService],
|
||||
exports: [VerificationService],
|
||||
})
|
||||
export class VerificationModule {}
|
||||
72
src/verification/verification.service.ts
Normal file
72
src/verification/verification.service.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { Injectable, BadRequestException } from '@nestjs/common';
|
||||
import { randomInt } from 'node:crypto';
|
||||
import { PinoLogger } from 'nestjs-pino';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { MailService } from '../mail/mail.service';
|
||||
|
||||
@Injectable()
|
||||
export class VerificationService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly mailService: MailService,
|
||||
private readonly logger: PinoLogger,
|
||||
) {
|
||||
this.logger.setContext(VerificationService.name);
|
||||
}
|
||||
|
||||
generateCode(): string {
|
||||
return randomInt(100000, 999999).toString();
|
||||
}
|
||||
|
||||
async sendEmailCode(email: string): Promise<void> {
|
||||
const code = this.generateCode();
|
||||
await this.prisma.verificationCode.create({
|
||||
data: {
|
||||
target: email,
|
||||
channel: 'EMAIL',
|
||||
code,
|
||||
purpose: 'AUTH',
|
||||
expiresAt: new Date(Date.now() + 5 * 60 * 1000),
|
||||
},
|
||||
});
|
||||
await this.mailService.sendCode(email, code, 'AUTH');
|
||||
this.logger.info({ email }, 'Email verification code sent');
|
||||
}
|
||||
|
||||
async sendPhoneCode(phone: string): Promise<void> {
|
||||
const code = this.generateCode();
|
||||
await this.prisma.verificationCode.create({
|
||||
data: {
|
||||
target: phone,
|
||||
channel: 'SMS',
|
||||
code,
|
||||
purpose: 'AUTH',
|
||||
expiresAt: new Date(Date.now() + 5 * 60 * 1000),
|
||||
},
|
||||
});
|
||||
this.logger.info({ phone }, 'SMS verification code would be sent (mock)');
|
||||
}
|
||||
|
||||
async verifyCode(target: string, code: string): Promise<boolean> {
|
||||
const record = await this.prisma.verificationCode.findFirst({
|
||||
where: {
|
||||
target,
|
||||
code,
|
||||
usedAt: null,
|
||||
expiresAt: { gt: new Date() },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
if (!record) {
|
||||
throw new BadRequestException('Invalid or expired code');
|
||||
}
|
||||
|
||||
await this.prisma.verificationCode.update({
|
||||
where: { id: record.id },
|
||||
data: { usedAt: new Date() },
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
135
src/web/web.controller.ts
Normal file
135
src/web/web.controller.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import { Controller, Get, Render, Req, UseGuards } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import type { Request } from 'express';
|
||||
import { Public } from '../common/decorators/public.decorator';
|
||||
import { Roles } from '../common/decorators/roles.decorator';
|
||||
import { RolesGuard } from '../common/guards/roles.guard';
|
||||
import { RequestWithUser } from '../common/types';
|
||||
import { Role } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
@Controller()
|
||||
export class WebController {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
@Public()
|
||||
@Get('login')
|
||||
@Render('auth/login')
|
||||
loginPage() {
|
||||
return {};
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Get('register')
|
||||
@Render('auth/register')
|
||||
registerPage() {
|
||||
return {};
|
||||
}
|
||||
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@Get('profile')
|
||||
@Render('profile/index')
|
||||
async profilePage(@Req() req: Request) {
|
||||
const user = (req as RequestWithUser).user;
|
||||
const profile = await this.prisma.user.findUnique({
|
||||
where: { id: user.sub },
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
phone: true,
|
||||
displayName: true,
|
||||
avatarUrl: true,
|
||||
role: true,
|
||||
emailVerifiedAt: true,
|
||||
phoneVerifiedAt: true,
|
||||
createdAt: true,
|
||||
},
|
||||
});
|
||||
return { user: profile };
|
||||
}
|
||||
|
||||
@UseGuards(AuthGuard('jwt'), RolesGuard)
|
||||
@Roles(Role.ADMIN)
|
||||
@Get('admin')
|
||||
@Render('admin/users')
|
||||
async adminUsers(@Req() req: Request) {
|
||||
const jwtUser = (req as RequestWithUser).user;
|
||||
const users = await this.prisma.user.findMany({
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 50,
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
displayName: true,
|
||||
role: true,
|
||||
isActive: true,
|
||||
createdAt: true,
|
||||
_count: { select: { sessions: true } },
|
||||
},
|
||||
});
|
||||
const clients = await this.prisma.client.count();
|
||||
const totalUsers = await this.prisma.user.count();
|
||||
const activeSessions = await this.prisma.session.count({
|
||||
where: { isActive: true },
|
||||
});
|
||||
return {
|
||||
user: { email: jwtUser.email, role: jwtUser.role },
|
||||
users,
|
||||
stats: { users: totalUsers, clients, activeSessions },
|
||||
};
|
||||
}
|
||||
|
||||
@UseGuards(AuthGuard('jwt'), RolesGuard)
|
||||
@Roles(Role.ADMIN)
|
||||
@Get('admin/users')
|
||||
@Render('admin/users')
|
||||
async adminUsersPage(@Req() req: Request) {
|
||||
const jwtUser = (req as RequestWithUser).user;
|
||||
const users = await this.prisma.user.findMany({
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 50,
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
displayName: true,
|
||||
role: true,
|
||||
isActive: true,
|
||||
createdAt: true,
|
||||
_count: { select: { sessions: true } },
|
||||
},
|
||||
});
|
||||
const totalUsers = await this.prisma.user.count();
|
||||
const clients = await this.prisma.client.count();
|
||||
const activeSessions = await this.prisma.session.count({
|
||||
where: { isActive: true },
|
||||
});
|
||||
return {
|
||||
user: { email: jwtUser.email, role: jwtUser.role },
|
||||
users,
|
||||
stats: { users: totalUsers, clients, activeSessions },
|
||||
};
|
||||
}
|
||||
|
||||
@UseGuards(AuthGuard('jwt'), RolesGuard)
|
||||
@Roles(Role.ADMIN)
|
||||
@Get('admin/clients')
|
||||
@Render('admin/clients')
|
||||
async adminClientsPage(@Req() req: Request) {
|
||||
const jwtUser = (req as RequestWithUser).user;
|
||||
const clients = await this.prisma.client.findMany({
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 50,
|
||||
include: { owner: { select: { id: true, email: true } } },
|
||||
});
|
||||
const totalUsers = await this.prisma.user.count();
|
||||
const totalClients = await this.prisma.client.count();
|
||||
const activeSessions = await this.prisma.session.count({
|
||||
where: { isActive: true },
|
||||
});
|
||||
return {
|
||||
user: { email: jwtUser.email, role: jwtUser.role },
|
||||
clients,
|
||||
stats: { users: totalUsers, clients: totalClients, activeSessions },
|
||||
};
|
||||
}
|
||||
}
|
||||
7
src/web/web.module.ts
Normal file
7
src/web/web.module.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { WebController } from './web.controller';
|
||||
|
||||
@Module({
|
||||
controllers: [WebController],
|
||||
})
|
||||
export class WebModule {}
|
||||
29
test/app.e2e-spec.ts
Normal file
29
test/app.e2e-spec.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { INestApplication } from '@nestjs/common';
|
||||
import request from 'supertest';
|
||||
import { App } from 'supertest/types';
|
||||
import { AppModule } from './../src/app.module';
|
||||
|
||||
describe('AppController (e2e)', () => {
|
||||
let app: INestApplication<App>;
|
||||
|
||||
beforeEach(async () => {
|
||||
const moduleFixture: TestingModule = await Test.createTestingModule({
|
||||
imports: [AppModule],
|
||||
}).compile();
|
||||
|
||||
app = moduleFixture.createNestApplication();
|
||||
await app.init();
|
||||
});
|
||||
|
||||
it('/ (GET)', () => {
|
||||
return request(app.getHttpServer())
|
||||
.get('/')
|
||||
.expect(200)
|
||||
.expect('Hello World!');
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
9
test/jest-e2e.json
Normal file
9
test/jest-e2e.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"moduleFileExtensions": ["js", "json", "ts"],
|
||||
"rootDir": ".",
|
||||
"testEnvironment": "node",
|
||||
"testRegex": ".e2e-spec.ts$",
|
||||
"transform": {
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
}
|
||||
}
|
||||
4
tsconfig.build.json
Normal file
4
tsconfig.build.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"exclude": ["node_modules", "test", "dist", "prisma", "prisma.config.ts", "**/*spec.ts"]
|
||||
}
|
||||
25
tsconfig.json
Normal file
25
tsconfig.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "nodenext",
|
||||
"moduleResolution": "nodenext",
|
||||
"resolvePackageJsonExports": true,
|
||||
"esModuleInterop": true,
|
||||
"isolatedModules": true,
|
||||
"declaration": true,
|
||||
"removeComments": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"target": "ES2023",
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"baseUrl": "./",
|
||||
"incremental": true,
|
||||
"skipLibCheck": true,
|
||||
"strictNullChecks": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noImplicitAny": false,
|
||||
"strictBindCallApply": false,
|
||||
"noFallthroughCasesInSwitch": false
|
||||
}
|
||||
}
|
||||
121
views/admin/clients.ejs
Normal file
121
views/admin/clients.ejs
Normal file
@@ -0,0 +1,121 @@
|
||||
<% title = 'Admin - OAuth Clients'; %>
|
||||
<% include ../layouts/main.ejs %>
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<ul class="nav nav-tabs mb-3">
|
||||
<li class="nav-item"><a class="nav-link" href="/admin/users">Users</a></li>
|
||||
<li class="nav-item"><a class="nav-link active" href="/admin/clients">OAuth Clients</a></li>
|
||||
</ul>
|
||||
|
||||
<div class="card shadow">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<span>OAuth Clients (<%= clients.length %>)</span>
|
||||
<button class="btn btn-sm btn-primary" data-bs-toggle="modal" data-bs-target="#createClientModal">+ Add Client</button>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<table class="table table-striped mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Client ID</th>
|
||||
<th>Confidential</th>
|
||||
<th>Grants</th>
|
||||
<th>Owner</th>
|
||||
<th>Created</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<% clients.forEach(function(c) { %>
|
||||
<tr>
|
||||
<td><%= c.name %></td>
|
||||
<td><code><%= c.clientId %></code></td>
|
||||
<td><%= c.isConfidential ? 'Yes' : 'No' %></td>
|
||||
<td><%= c.grants.join(', ') %></td>
|
||||
<td><%= c.owner ? c.owner.email : '-' %></td>
|
||||
<td><%= new Date(c.createdAt).toLocaleDateString() %></td>
|
||||
<td>
|
||||
<button class="btn btn-sm btn-outline-danger" onclick="deleteClient('<%= c.id %>')">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
<% }); %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal fade" id="createClientModal" tabindex="-1">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Create OAuth Client</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form id="createClientForm">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Name</label>
|
||||
<input type="text" class="form-control" id="clientName" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Client ID</label>
|
||||
<input type="text" class="form-control" id="clientId" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Description</label>
|
||||
<input type="text" class="form-control" id="clientDesc">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Redirect URIs (comma separated)</label>
|
||||
<input type="text" class="form-control" id="clientUris" placeholder="http://localhost:5173/callback">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Grants (comma separated)</label>
|
||||
<input type="text" class="form-control" id="clientGrants" value="authorization_code,refresh_token">
|
||||
</div>
|
||||
<div class="mb-3 form-check">
|
||||
<input type="checkbox" class="form-check-input" id="clientConfidential" checked>
|
||||
<label class="form-check-label">Confidential</label>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-primary" onclick="createClient()">Create</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const token = localStorage.getItem('access_token');
|
||||
if (!token) window.location.href = '/login';
|
||||
|
||||
async function createClient() {
|
||||
const res = await fetch('/api/admin/clients', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` },
|
||||
body: JSON.stringify({
|
||||
name: document.getElementById('clientName').value,
|
||||
clientId: document.getElementById('clientId').value,
|
||||
description: document.getElementById('clientDesc').value,
|
||||
redirectUris: document.getElementById('clientUris').value.split(',').map(s => s.trim()),
|
||||
grants: document.getElementById('clientGrants').value.split(',').map(s => s.trim()),
|
||||
isConfidential: document.getElementById('clientConfidential').checked,
|
||||
}),
|
||||
});
|
||||
if (res.ok) location.reload();
|
||||
else alert('Failed to create client');
|
||||
}
|
||||
|
||||
async function deleteClient(id) {
|
||||
if (!confirm('Delete this client?')) return;
|
||||
const res = await fetch(`/api/admin/clients/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Authorization': `Bearer ${token}` },
|
||||
});
|
||||
if (res.ok) location.reload();
|
||||
else alert('Failed to delete client');
|
||||
}
|
||||
</script>
|
||||
141
views/admin/users.ejs
Normal file
141
views/admin/users.ejs
Normal file
@@ -0,0 +1,141 @@
|
||||
<% title = 'Admin'; %>
|
||||
<% include ../layouts/main.ejs %>
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-4">
|
||||
<div class="card text-bg-primary">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Users</h5>
|
||||
<h2><%= stats.users %></h2>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="card text-bg-success">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">OAuth Clients</h5>
|
||||
<h2><%= stats.clients %></h2>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="card text-bg-info">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Active Sessions</h5>
|
||||
<h2><%= stats.activeSessions %></h2>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul class="nav nav-tabs mb-3">
|
||||
<li class="nav-item"><a class="nav-link active" href="/admin/users">Users</a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="/admin/clients">OAuth Clients</a></li>
|
||||
</ul>
|
||||
|
||||
<div class="card shadow">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<span>Users (<%= users.length %>)</span>
|
||||
<button class="btn btn-sm btn-primary" data-bs-toggle="modal" data-bs-target="#createUserModal">+ Add User</button>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<table class="table table-striped mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Email</th>
|
||||
<th>Name</th>
|
||||
<th>Role</th>
|
||||
<th>Active</th>
|
||||
<th>Sessions</th>
|
||||
<th>Created</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<% users.forEach(function(u) { %>
|
||||
<tr>
|
||||
<td><%= u.email %></td>
|
||||
<td><%= u.displayName || '-' %></td>
|
||||
<td><span class="badge bg-<%= u.role === 'ADMIN' ? 'danger' : 'secondary' %>"><%= u.role %></span></td>
|
||||
<td><%= u.isActive ? 'Yes' : 'No' %></td>
|
||||
<td><%= u._count.sessions %></td>
|
||||
<td><%= new Date(u.createdAt).toLocaleDateString() %></td>
|
||||
<td>
|
||||
<button class="btn btn-sm btn-outline-danger" onclick="deleteUser('<%= u.id %>')">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
<% }); %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal fade" id="createUserModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Create User</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form id="createUserForm">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Email</label>
|
||||
<input type="email" class="form-control" id="newUserEmail" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Password</label>
|
||||
<input type="password" class="form-control" id="newUserPassword" minlength="8" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Display Name</label>
|
||||
<input type="text" class="form-control" id="newUserName">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Role</label>
|
||||
<select class="form-control" id="newUserRole">
|
||||
<option value="USER">User</option>
|
||||
<option value="ADMIN">Admin</option>
|
||||
</select>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-primary" onclick="createUser()">Create</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const token = localStorage.getItem('access_token');
|
||||
if (!token) window.location.href = '/login';
|
||||
|
||||
async function createUser() {
|
||||
const res = await fetch('/api/admin/users', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` },
|
||||
body: JSON.stringify({
|
||||
email: document.getElementById('newUserEmail').value,
|
||||
password: document.getElementById('newUserPassword').value,
|
||||
displayName: document.getElementById('newUserName').value,
|
||||
role: document.getElementById('newUserRole').value,
|
||||
}),
|
||||
});
|
||||
if (res.ok) location.reload();
|
||||
else alert('Failed to create user');
|
||||
}
|
||||
|
||||
async function deleteUser(id) {
|
||||
if (!confirm('Delete this user?')) return;
|
||||
const res = await fetch(`/api/admin/users/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Authorization': `Bearer ${token}` },
|
||||
});
|
||||
if (res.ok) location.reload();
|
||||
else alert('Failed to delete user');
|
||||
}
|
||||
</script>
|
||||
168
views/auth/login.ejs
Normal file
168
views/auth/login.ejs
Normal file
@@ -0,0 +1,168 @@
|
||||
<% title = 'Login'; %>
|
||||
<% include ../layouts/main.ejs %>
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-5">
|
||||
<div class="card shadow">
|
||||
<div class="card-body p-4">
|
||||
<h3 class="card-title text-center mb-4">Sign In</h3>
|
||||
|
||||
<ul class="nav nav-tabs mb-3" id="loginTabs" role="tablist">
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link active" id="password-tab" data-bs-toggle="tab" data-bs-target="#password" type="button">Password</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="email-code-tab" data-bs-toggle="tab" data-bs-target="#email-code" type="button">Email Code</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="qr-tab" data-bs-toggle="tab" data-bs-target="#qr" type="button">QR Code</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="tab-content">
|
||||
<div class="tab-pane fade show active" id="password">
|
||||
<form id="loginForm">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Email</label>
|
||||
<input type="email" class="form-control" id="loginEmail" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Password</label>
|
||||
<input type="password" class="form-control" id="loginPassword" required>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary w-100">Sign In</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="tab-pane fade" id="email-code">
|
||||
<form id="emailCodeForm">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Email</label>
|
||||
<input type="email" class="form-control" id="codeEmail" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Code</label>
|
||||
<div class="input-group">
|
||||
<input type="text" class="form-control" id="verificationCode" placeholder="000000" required>
|
||||
<button type="button" class="btn btn-outline-secondary" id="sendCodeBtn">Send Code</button>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary w-100">Sign In with Code</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="tab-pane fade" id="qr">
|
||||
<div class="text-center">
|
||||
<p class="mb-2">Scan with mobile app</p>
|
||||
<div id="qrContainer" class="mb-3">
|
||||
<button class="btn btn-outline-primary" id="showQrBtn">Show QR Code</button>
|
||||
</div>
|
||||
<div id="qrStatus" class="alert d-none"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="errorMessage" class="alert alert-danger mt-3 d-none"></div>
|
||||
<div id="successMessage" class="alert alert-success mt-3 d-none"></div>
|
||||
|
||||
<p class="text-center mt-3 mb-0">
|
||||
<a href="/register">Don't have an account? Register</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const API = '/api';
|
||||
|
||||
document.getElementById('loginForm').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const email = document.getElementById('loginEmail').value;
|
||||
const password = document.getElementById('loginPassword').value;
|
||||
try {
|
||||
const res = await fetch(`${API}/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
localStorage.setItem('access_token', data.accessToken);
|
||||
localStorage.setItem('refresh_token', data.refreshToken);
|
||||
window.location.href = '/profile';
|
||||
} else {
|
||||
showError(data.message || 'Login failed');
|
||||
}
|
||||
} catch (err) {
|
||||
showError('Network error');
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('sendCodeBtn').addEventListener('click', async () => {
|
||||
const email = document.getElementById('codeEmail').value;
|
||||
if (!email) return;
|
||||
try {
|
||||
await fetch(`${API}/auth/send-email-code`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email }),
|
||||
});
|
||||
alert('Code sent!');
|
||||
} catch { alert('Failed to send code'); }
|
||||
});
|
||||
|
||||
document.getElementById('emailCodeForm').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const email = document.getElementById('codeEmail').value;
|
||||
const code = document.getElementById('verificationCode').value;
|
||||
try {
|
||||
const res = await fetch(`${API}/auth/login/email-code`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, code }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
localStorage.setItem('access_token', data.accessToken);
|
||||
localStorage.setItem('refresh_token', data.refreshToken);
|
||||
window.location.href = '/profile';
|
||||
} else {
|
||||
showError(data.message || 'Login failed');
|
||||
}
|
||||
} catch { showError('Network error'); }
|
||||
});
|
||||
|
||||
document.getElementById('showQrBtn').addEventListener('click', async () => {
|
||||
try {
|
||||
const res = await fetch(`${API}/auth/qr/init`, { method: 'POST' });
|
||||
const data = await res.json();
|
||||
const container = document.getElementById('qrContainer');
|
||||
container.innerHTML = `<img src="${data.qrDataUrl}" alt="QR Code" class="img-fluid" style="max-width:250px">`;
|
||||
document.getElementById('qrStatus').className = 'alert alert-info';
|
||||
document.getElementById('qrStatus').textContent = 'Scan with mobile app...';
|
||||
const poll = setInterval(async () => {
|
||||
const pollRes = await fetch(`${API}/auth/qr/poll`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ sessionId: data.sessionId }),
|
||||
});
|
||||
const pollData = await pollRes.json();
|
||||
if (pollData.status === 'CONFIRMED') {
|
||||
clearInterval(poll);
|
||||
localStorage.setItem('access_token', pollData.accessToken);
|
||||
window.location.href = '/profile';
|
||||
} else if (pollData.status === 'EXPIRED') {
|
||||
clearInterval(poll);
|
||||
document.getElementById('qrStatus').className = 'alert alert-danger';
|
||||
document.getElementById('qrStatus').textContent = 'QR expired. Try again.';
|
||||
}
|
||||
}, 2000);
|
||||
} catch { showError('Failed to load QR'); }
|
||||
});
|
||||
|
||||
function showError(msg) {
|
||||
const el = document.getElementById('errorMessage');
|
||||
el.textContent = Array.isArray(msg) ? msg.join(', ') : msg;
|
||||
el.classList.remove('d-none');
|
||||
}
|
||||
</script>
|
||||
60
views/auth/register.ejs
Normal file
60
views/auth/register.ejs
Normal file
@@ -0,0 +1,60 @@
|
||||
<% title = 'Register'; %>
|
||||
<% include ../layouts/main.ejs %>
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-5">
|
||||
<div class="card shadow">
|
||||
<div class="card-body p-4">
|
||||
<h3 class="card-title text-center mb-4">Create Account</h3>
|
||||
<form id="registerForm">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Email</label>
|
||||
<input type="email" class="form-control" id="regEmail" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Display Name</label>
|
||||
<input type="text" class="form-control" id="regName">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Password</label>
|
||||
<input type="password" class="form-control" id="regPassword" minlength="8" required>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary w-100">Register</button>
|
||||
</form>
|
||||
<div id="errorMessage" class="alert alert-danger mt-3 d-none"></div>
|
||||
<p class="text-center mt-3 mb-0">
|
||||
<a href="/login">Already have an account? Sign In</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.getElementById('registerForm').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const email = document.getElementById('regEmail').value;
|
||||
const password = document.getElementById('regPassword').value;
|
||||
const displayName = document.getElementById('regName').value;
|
||||
try {
|
||||
const res = await fetch('/api/auth/register', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password, displayName }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
localStorage.setItem('access_token', data.accessToken);
|
||||
localStorage.setItem('refresh_token', data.refreshToken);
|
||||
window.location.href = '/profile';
|
||||
} else {
|
||||
const el = document.getElementById('errorMessage');
|
||||
el.textContent = Array.isArray(data.message) ? data.message.join(', ') : data.message || 'Registration failed';
|
||||
el.classList.remove('d-none');
|
||||
}
|
||||
} catch {
|
||||
const el = document.getElementById('errorMessage');
|
||||
el.textContent = 'Network error';
|
||||
el.classList.remove('d-none');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
44
views/layouts/main.ejs
Normal file
44
views/layouts/main.ejs
Normal file
@@ -0,0 +1,44 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>SSO - <%= title || 'Auth Panel' %></title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
</head>
|
||||
<body class="bg-light">
|
||||
<nav class="navbar navbar-expand-lg navbar-dark bg-dark mb-4">
|
||||
<div class="container">
|
||||
<a class="navbar-brand" href="/">SSO Service</a>
|
||||
<div class="collapse navbar-collapse">
|
||||
<ul class="navbar-nav ms-auto">
|
||||
<% if (locals.user) { %>
|
||||
<li class="nav-item"><a class="nav-link" href="/profile">Profile</a></li>
|
||||
<% if (user.role === 'ADMIN') { %>
|
||||
<li class="nav-item"><a class="nav-link" href="/admin">Admin</a></li>
|
||||
<% } %>
|
||||
<li class="nav-item"><a class="nav-link" href="#" onclick="logout()">Logout</a></li>
|
||||
<% } else { %>
|
||||
<li class="nav-item"><a class="nav-link" href="/login">Login</a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="/register">Register</a></li>
|
||||
<% } %>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="container">
|
||||
<%- body %>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script>
|
||||
function logout() {
|
||||
localStorage.removeItem('access_token');
|
||||
localStorage.removeItem('refresh_token');
|
||||
window.location.href = '/login';
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
72
views/profile/index.ejs
Normal file
72
views/profile/index.ejs
Normal file
@@ -0,0 +1,72 @@
|
||||
<% title = 'Profile'; %>
|
||||
<% include ../layouts/main.ejs %>
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-6">
|
||||
<div class="card shadow">
|
||||
<div class="card-body p-4">
|
||||
<h3 class="card-title mb-4">My Profile</h3>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-bold">Email</label>
|
||||
<p class="form-control-plaintext"><%= user.email %></p>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-bold">Display Name</label>
|
||||
<input type="text" class="form-control" id="displayName" value="<%= user.displayName || '' %>">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-bold">Phone</label>
|
||||
<input type="tel" class="form-control" id="phone" value="<%= user.phone || '' %>">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-bold">Role</label>
|
||||
<p class="form-control-plaintext"><%= user.role %></p>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-bold">Email Verified</label>
|
||||
<p class="form-control-plaintext"><%= user.emailVerifiedAt ? 'Yes' : 'No' %></p>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-bold">Member Since</label>
|
||||
<p class="form-control-plaintext"><%= new Date(user.createdAt).toLocaleDateString() %></p>
|
||||
</div>
|
||||
<button class="btn btn-primary w-100" onclick="updateProfile()">Save Changes</button>
|
||||
<div id="message" class="alert d-none mt-3"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
async function updateProfile() {
|
||||
const token = localStorage.getItem('access_token');
|
||||
if (!token) { window.location.href = '/login'; return; }
|
||||
try {
|
||||
const res = await fetch('/api/auth/profile/update', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
displayName: document.getElementById('displayName').value,
|
||||
phone: document.getElementById('phone').value,
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
const msg = document.getElementById('message');
|
||||
if (res.ok) {
|
||||
msg.className = 'alert alert-success';
|
||||
msg.textContent = 'Profile updated!';
|
||||
} else {
|
||||
msg.className = 'alert alert-danger';
|
||||
msg.textContent = data.message || 'Update failed';
|
||||
}
|
||||
msg.classList.remove('d-none');
|
||||
} catch {
|
||||
const msg = document.getElementById('message');
|
||||
msg.className = 'alert alert-danger';
|
||||
msg.textContent = 'Network error';
|
||||
msg.classList.remove('d-none');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
Reference in New Issue
Block a user