first commit

This commit is contained in:
Дмитрий Мамедов
2026-06-10 16:23:41 +03:00
commit f37733a5c9
70 changed files with 16338 additions and 0 deletions

9
.dockerignore Normal file
View File

@@ -0,0 +1,9 @@
node_modules
dist
.git
.gitignore
.env
.env.example
*.md
coverage
test

63
.env.example Normal file
View 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
View 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
View File

@@ -0,0 +1,4 @@
{
"singleQuote": true,
"trailingComma": "all"
}

21
Dockerfile Normal file
View 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
View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

107
package.json Normal file
View 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
View 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
View 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
View 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
View 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; }

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

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

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

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

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

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

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

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

View File

@@ -0,0 +1,4 @@
import { SetMetadata } from '@nestjs/common';
export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);

View 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);

View 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,
});
}
}

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

View 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),
},
}));

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

View 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 {}

View 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
View 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
View 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}\ействителен 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
View 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();

View 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 {}

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

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

View 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
View 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
View 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,
};
}
}

View 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 {}

View 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();
}
}

View File

@@ -0,0 +1,4 @@
import { Module } from '@nestjs/common';
@Module({})
export class ProfileModule {}

View File

@@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { QrAuthService } from './qr-auth.service';
@Module({
providers: [QrAuthService],
exports: [QrAuthService],
})
export class QrAuthModule {}

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

View 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 {}

View 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
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "test", "dist", "prisma", "prisma.config.ts", "**/*spec.ts"]
}

25
tsconfig.json Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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>