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