first commit
This commit is contained in:
94
src/admin/admin.controller.ts
Normal file
94
src/admin/admin.controller.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Put,
|
||||
Delete,
|
||||
Body,
|
||||
Param,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { Role } from '@prisma/client';
|
||||
import { Roles } from '../common/decorators/roles.decorator';
|
||||
import { RolesGuard } from '../common/guards/roles.guard';
|
||||
import { AdminService } from './admin.service';
|
||||
import { CreateUserDto, UpdateUserDto } from './dto/create-user.dto';
|
||||
import { CreateClientDto, UpdateClientDto } from './dto/create-client.dto';
|
||||
|
||||
@Controller('admin')
|
||||
@UseGuards(AuthGuard('jwt'), RolesGuard)
|
||||
@Roles(Role.ADMIN)
|
||||
export class AdminController {
|
||||
constructor(private readonly adminService: AdminService) {}
|
||||
|
||||
@Get('stats')
|
||||
async stats() {
|
||||
return this.adminService.getStats();
|
||||
}
|
||||
|
||||
@Get('users')
|
||||
async listUsers(
|
||||
@Query('page') page?: string,
|
||||
@Query('limit') limit?: string,
|
||||
) {
|
||||
return this.adminService.listUsers(
|
||||
page ? parseInt(page, 10) : 1,
|
||||
limit ? parseInt(limit, 10) : 20,
|
||||
);
|
||||
}
|
||||
|
||||
@Get('users/:id')
|
||||
async getUser(@Param('id') id: string) {
|
||||
return this.adminService.getUser(id);
|
||||
}
|
||||
|
||||
@Post('users')
|
||||
async createUser(@Body() dto: CreateUserDto) {
|
||||
return this.adminService.createUser(dto);
|
||||
}
|
||||
|
||||
@Put('users/:id')
|
||||
async updateUser(@Param('id') id: string, @Body() dto: UpdateUserDto) {
|
||||
return this.adminService.updateUser(id, dto);
|
||||
}
|
||||
|
||||
@Delete('users/:id')
|
||||
async deleteUser(@Param('id') id: string) {
|
||||
await this.adminService.deleteUser(id);
|
||||
return { deleted: true };
|
||||
}
|
||||
|
||||
@Get('clients')
|
||||
async listClients(
|
||||
@Query('page') page?: string,
|
||||
@Query('limit') limit?: string,
|
||||
) {
|
||||
return this.adminService.listClients(
|
||||
page ? parseInt(page, 10) : 1,
|
||||
limit ? parseInt(limit, 10) : 20,
|
||||
);
|
||||
}
|
||||
|
||||
@Get('clients/:id')
|
||||
async getClient(@Param('id') id: string) {
|
||||
return this.adminService.getClient(id);
|
||||
}
|
||||
|
||||
@Post('clients')
|
||||
async createClient(@Body() dto: CreateClientDto) {
|
||||
return this.adminService.createClient(dto);
|
||||
}
|
||||
|
||||
@Put('clients/:id')
|
||||
async updateClient(@Param('id') id: string, @Body() dto: UpdateClientDto) {
|
||||
return this.adminService.updateClient(id, dto);
|
||||
}
|
||||
|
||||
@Delete('clients/:id')
|
||||
async deleteClient(@Param('id') id: string) {
|
||||
await this.adminService.deleteClient(id);
|
||||
return { deleted: true };
|
||||
}
|
||||
}
|
||||
9
src/admin/admin.module.ts
Normal file
9
src/admin/admin.module.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AdminController } from './admin.controller';
|
||||
import { AdminService } from './admin.service';
|
||||
|
||||
@Module({
|
||||
controllers: [AdminController],
|
||||
providers: [AdminService],
|
||||
})
|
||||
export class AdminModule {}
|
||||
186
src/admin/admin.service.ts
Normal file
186
src/admin/admin.service.ts
Normal file
@@ -0,0 +1,186 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import * as argon2 from 'argon2';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { PinoLogger } from 'nestjs-pino';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { CreateUserDto, UpdateUserDto } from './dto/create-user.dto';
|
||||
import { CreateClientDto, UpdateClientDto } from './dto/create-client.dto';
|
||||
|
||||
@Injectable()
|
||||
export class AdminService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly logger: PinoLogger,
|
||||
) {
|
||||
this.logger.setContext(AdminService.name);
|
||||
}
|
||||
|
||||
async listUsers(page = 1, limit = 20) {
|
||||
const skip = (page - 1) * limit;
|
||||
const [users, total] = await Promise.all([
|
||||
this.prisma.user.findMany({
|
||||
skip,
|
||||
take: limit,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
phone: true,
|
||||
displayName: true,
|
||||
role: true,
|
||||
isActive: true,
|
||||
emailVerifiedAt: true,
|
||||
createdAt: true,
|
||||
_count: { select: { sessions: true } },
|
||||
},
|
||||
}),
|
||||
this.prisma.user.count(),
|
||||
]);
|
||||
return { users, total, page, limit, totalPages: Math.ceil(total / limit) };
|
||||
}
|
||||
|
||||
async getUser(id: string) {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id },
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
phone: true,
|
||||
displayName: true,
|
||||
avatarUrl: true,
|
||||
role: true,
|
||||
isActive: true,
|
||||
emailVerifiedAt: true,
|
||||
phoneVerifiedAt: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
_count: { select: { sessions: true, auditLogs: true } },
|
||||
},
|
||||
});
|
||||
if (!user) throw new NotFoundException('User not found');
|
||||
return user;
|
||||
}
|
||||
|
||||
async createUser(dto: CreateUserDto) {
|
||||
const passwordHash = await argon2.hash(dto.password);
|
||||
return this.prisma.user.create({
|
||||
data: {
|
||||
email: dto.email,
|
||||
passwordHash,
|
||||
displayName: dto.displayName,
|
||||
phone: dto.phone,
|
||||
role: dto.role ?? 'USER',
|
||||
},
|
||||
select: { id: true, email: true, displayName: true, role: true },
|
||||
});
|
||||
}
|
||||
|
||||
async updateUser(id: string, dto: UpdateUserDto) {
|
||||
const user = await this.prisma.user.findUnique({ where: { id } });
|
||||
if (!user) throw new NotFoundException('User not found');
|
||||
|
||||
const data: Record<string, unknown> = {};
|
||||
if (dto.email) data.email = dto.email;
|
||||
if (dto.displayName) data.displayName = dto.displayName;
|
||||
if (dto.phone) data.phone = dto.phone;
|
||||
if (dto.role) data.role = dto.role;
|
||||
if (dto.isActive !== undefined) data.isActive = dto.isActive;
|
||||
if (dto.password) data.passwordHash = await argon2.hash(dto.password);
|
||||
|
||||
return this.prisma.user.update({
|
||||
where: { id },
|
||||
data,
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
displayName: true,
|
||||
role: true,
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async deleteUser(id: string) {
|
||||
const user = await this.prisma.user.findUnique({ where: { id } });
|
||||
if (!user) throw new NotFoundException('User not found');
|
||||
await this.prisma.user.delete({ where: { id } });
|
||||
}
|
||||
|
||||
async listClients(page = 1, limit = 20) {
|
||||
const skip = (page - 1) * limit;
|
||||
const [clients, total] = await Promise.all([
|
||||
this.prisma.client.findMany({
|
||||
skip,
|
||||
take: limit,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: { owner: { select: { id: true, email: true } } },
|
||||
}),
|
||||
this.prisma.client.count(),
|
||||
]);
|
||||
return {
|
||||
clients,
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
};
|
||||
}
|
||||
|
||||
async getClient(id: string) {
|
||||
const client = await this.prisma.client.findUnique({
|
||||
where: { id },
|
||||
include: { owner: { select: { id: true, email: true } } },
|
||||
});
|
||||
if (!client) throw new NotFoundException('Client not found');
|
||||
return client;
|
||||
}
|
||||
|
||||
async createClient(dto: CreateClientDto, ownerId?: string) {
|
||||
const clientSecret = dto.clientSecret ?? randomBytes(32).toString('hex');
|
||||
return this.prisma.client.create({
|
||||
data: {
|
||||
name: dto.name,
|
||||
description: dto.description,
|
||||
clientId: dto.clientId,
|
||||
clientSecret,
|
||||
redirectUris: dto.redirectUris,
|
||||
grants: dto.grants,
|
||||
isConfidential: dto.isConfidential ?? true,
|
||||
logoUrl: dto.logoUrl,
|
||||
ownerId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async updateClient(id: string, dto: UpdateClientDto) {
|
||||
const client = await this.prisma.client.findUnique({ where: { id } });
|
||||
if (!client) throw new NotFoundException('Client not found');
|
||||
|
||||
const data: Record<string, unknown> = {};
|
||||
if (dto.name) data.name = dto.name;
|
||||
if (dto.description !== undefined) data.description = dto.description;
|
||||
if (dto.clientSecret) data.clientSecret = dto.clientSecret;
|
||||
if (dto.redirectUris) data.redirectUris = dto.redirectUris;
|
||||
if (dto.grants) data.grants = dto.grants;
|
||||
if (dto.isConfidential !== undefined)
|
||||
data.isConfidential = dto.isConfidential;
|
||||
if (dto.logoUrl !== undefined) data.logoUrl = dto.logoUrl;
|
||||
|
||||
return this.prisma.client.update({ where: { id }, data });
|
||||
}
|
||||
|
||||
async deleteClient(id: string) {
|
||||
const client = await this.prisma.client.findUnique({ where: { id } });
|
||||
if (!client) throw new NotFoundException('Client not found');
|
||||
await this.prisma.client.delete({ where: { id } });
|
||||
}
|
||||
|
||||
async getStats() {
|
||||
const [users, clients, activeSessions] = await Promise.all([
|
||||
this.prisma.user.count(),
|
||||
this.prisma.client.count(),
|
||||
this.prisma.session.count({ where: { isActive: true } }),
|
||||
]);
|
||||
return { users, clients, activeSessions };
|
||||
}
|
||||
}
|
||||
61
src/admin/dto/create-client.dto.ts
Normal file
61
src/admin/dto/create-client.dto.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { IsString, IsOptional, IsArray, IsBoolean } from 'class-validator';
|
||||
|
||||
export class CreateClientDto {
|
||||
@IsString()
|
||||
name!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
description?: string;
|
||||
|
||||
@IsString()
|
||||
clientId!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
clientSecret?: string;
|
||||
|
||||
@IsArray()
|
||||
redirectUris!: string[];
|
||||
|
||||
@IsArray()
|
||||
grants!: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isConfidential?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
logoUrl?: string;
|
||||
}
|
||||
|
||||
export class UpdateClientDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
name?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
description?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
clientSecret?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
redirectUris?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
grants?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isConfidential?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
logoUrl?: string;
|
||||
}
|
||||
55
src/admin/dto/create-user.dto.ts
Normal file
55
src/admin/dto/create-user.dto.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import {
|
||||
IsEmail,
|
||||
IsString,
|
||||
MinLength,
|
||||
IsOptional,
|
||||
IsEnum,
|
||||
} from 'class-validator';
|
||||
import { Role } from '@prisma/client';
|
||||
|
||||
export class CreateUserDto {
|
||||
@IsEmail()
|
||||
email!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(8)
|
||||
password!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
displayName?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
phone?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(Role)
|
||||
role?: Role;
|
||||
}
|
||||
|
||||
export class UpdateUserDto {
|
||||
@IsOptional()
|
||||
@IsEmail()
|
||||
email?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(8)
|
||||
password?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
displayName?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
phone?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(Role)
|
||||
role?: Role;
|
||||
|
||||
@IsOptional()
|
||||
isActive?: boolean;
|
||||
}
|
||||
22
src/app.controller.spec.ts
Normal file
22
src/app.controller.spec.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { AppController } from './app.controller';
|
||||
import { AppService } from './app.service';
|
||||
|
||||
describe('AppController', () => {
|
||||
let appController: AppController;
|
||||
|
||||
beforeEach(async () => {
|
||||
const app: TestingModule = await Test.createTestingModule({
|
||||
controllers: [AppController],
|
||||
providers: [AppService],
|
||||
}).compile();
|
||||
|
||||
appController = app.get<AppController>(AppController);
|
||||
});
|
||||
|
||||
describe('root', () => {
|
||||
it('should return "Hello World!"', () => {
|
||||
expect(appController.getHello()).toBe('Hello World!');
|
||||
});
|
||||
});
|
||||
});
|
||||
12
src/app.controller.ts
Normal file
12
src/app.controller.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import { AppService } from './app.service';
|
||||
|
||||
@Controller()
|
||||
export class AppController {
|
||||
constructor(private readonly appService: AppService) {}
|
||||
|
||||
@Get()
|
||||
getHello(): string {
|
||||
return this.appService.getHello();
|
||||
}
|
||||
}
|
||||
82
src/app.module.ts
Normal file
82
src/app.module.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { ThrottlerModule } from '@nestjs/throttler';
|
||||
import { APP_FILTER, APP_GUARD } from '@nestjs/core';
|
||||
import { LoggerModule } from 'nestjs-pino';
|
||||
import { PrismaModule } from './prisma/prisma.module';
|
||||
import { AuthModule } from './auth/auth.module';
|
||||
import { OidcModule } from './oidc/oidc.module';
|
||||
import { EventsModule } from './events/events.module';
|
||||
import { MetricsModule } from './metrics/metrics.module';
|
||||
import { AdminModule } from './admin/admin.module';
|
||||
import { WebModule } from './web/web.module';
|
||||
import { MailModule } from './mail/mail.module';
|
||||
import { VerificationModule } from './verification/verification.module';
|
||||
import { QrAuthModule } from './qr-auth/qr-auth.module';
|
||||
import { AllExceptionsFilter } from './common/filters/all-exceptions.filter';
|
||||
import { JwtAuthGuard } from './auth/strategies/jwt-auth.guard';
|
||||
import { RolesGuard } from './common/guards/roles.guard';
|
||||
import { validateEnv } from './config/env.validation';
|
||||
import appConfig from './config/configuration';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
validate: validateEnv,
|
||||
load: [appConfig],
|
||||
}),
|
||||
LoggerModule.forRoot({
|
||||
pinoHttp: {
|
||||
autoLogging: {
|
||||
ignore: (req) => (req as { url?: string }).url === '/metrics',
|
||||
},
|
||||
serializers: {
|
||||
req: (req) => ({
|
||||
method: (req as { method?: string }).method,
|
||||
url: (req as { url?: string }).url,
|
||||
remoteAddress: (req as { remoteAddress?: string }).remoteAddress,
|
||||
}),
|
||||
res: (res) => ({
|
||||
statusCode: (res as { statusCode?: number }).statusCode,
|
||||
}),
|
||||
},
|
||||
redact: {
|
||||
paths: [
|
||||
'req.headers.authorization',
|
||||
'req.body.password',
|
||||
'req.body.refreshToken',
|
||||
'req.body.client_secret',
|
||||
],
|
||||
censor: '[REDACTED]',
|
||||
},
|
||||
transport:
|
||||
process.env.NODE_ENV !== 'production'
|
||||
? { target: 'pino-pretty', options: { colorize: true } }
|
||||
: undefined,
|
||||
},
|
||||
}),
|
||||
ThrottlerModule.forRoot([
|
||||
{
|
||||
ttl: parseInt(process.env.THROTTLE_TTL ?? '60000', 10),
|
||||
limit: parseInt(process.env.THROTTLE_LIMIT ?? '10', 10),
|
||||
},
|
||||
]),
|
||||
PrismaModule,
|
||||
EventsModule,
|
||||
MailModule,
|
||||
VerificationModule,
|
||||
QrAuthModule,
|
||||
AuthModule,
|
||||
OidcModule,
|
||||
MetricsModule,
|
||||
AdminModule,
|
||||
WebModule,
|
||||
],
|
||||
providers: [
|
||||
{ provide: APP_FILTER, useClass: AllExceptionsFilter },
|
||||
{ provide: APP_GUARD, useClass: JwtAuthGuard },
|
||||
{ provide: APP_GUARD, useClass: RolesGuard },
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
8
src/app.service.ts
Normal file
8
src/app.service.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
@Injectable()
|
||||
export class AppService {
|
||||
getHello(): string {
|
||||
return 'Hello World!';
|
||||
}
|
||||
}
|
||||
140
src/auth/auth.controller.ts
Normal file
140
src/auth/auth.controller.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import {
|
||||
Controller,
|
||||
Post,
|
||||
Get,
|
||||
Body,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Req,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { Throttle } from '@nestjs/throttler';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import type { Request } from 'express';
|
||||
import { Public } from '../common/decorators/public.decorator';
|
||||
import { RequestWithUser } from '../common/types';
|
||||
import { AuthService } from './auth.service';
|
||||
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
constructor(private readonly authService: AuthService) {}
|
||||
|
||||
@Public()
|
||||
@Post('register')
|
||||
async register(
|
||||
@Body() body: { email: string; password: string; displayName?: string },
|
||||
@Req() req: Request,
|
||||
) {
|
||||
return this.authService.register(body, req.ip, req.headers['user-agent']);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Throttle({ default: { ttl: 60000, limit: 5 } })
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('login')
|
||||
async login(
|
||||
@Body() body: { email: string; password: string },
|
||||
@Req() req: Request,
|
||||
) {
|
||||
return this.authService.login(body, req.ip, req.headers['user-agent']);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post('login/email-code')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async loginByEmailCode(
|
||||
@Body() body: { email: string; code: string },
|
||||
@Req() req: Request,
|
||||
) {
|
||||
return this.authService.loginByEmailCode(
|
||||
body.email,
|
||||
body.code,
|
||||
req.ip,
|
||||
req.headers['user-agent'],
|
||||
);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post('login/phone-code')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async loginByPhoneCode(
|
||||
@Body() body: { phone: string; code: string },
|
||||
@Req() req: Request,
|
||||
) {
|
||||
return this.authService.loginByPhoneCode(
|
||||
body.phone,
|
||||
body.code,
|
||||
req.ip,
|
||||
req.headers['user-agent'],
|
||||
);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post('send-email-code')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async sendEmailCode(@Body() body: { email: string }) {
|
||||
await this.authService.sendEmailCode(body.email);
|
||||
return { sent: true };
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post('send-phone-code')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async sendPhoneCode(@Body() body: { phone: string }) {
|
||||
await this.authService.sendPhoneCode(body.phone);
|
||||
return { sent: true };
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post('qr/init')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async initQr() {
|
||||
return this.authService.initQrSession();
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post('qr/poll')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async pollQr(@Body() body: { sessionId: string }) {
|
||||
return this.authService.pollQrSession(body.sessionId);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('refresh')
|
||||
async refresh(
|
||||
@Body('refreshToken') refreshToken: string,
|
||||
@Req() req: Request,
|
||||
) {
|
||||
return this.authService.refresh(
|
||||
refreshToken,
|
||||
req.ip,
|
||||
req.headers['user-agent'],
|
||||
);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@Post('logout')
|
||||
async logout(@Body('refreshToken') refreshToken: string) {
|
||||
await this.authService.logout(refreshToken);
|
||||
}
|
||||
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@Get('profile')
|
||||
async profile(@Req() req: Request) {
|
||||
const user = (req as RequestWithUser).user;
|
||||
return this.authService.getProfile(user.sub);
|
||||
}
|
||||
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@Post('profile/update')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async updateProfile(
|
||||
@Body() body: { displayName?: string; avatarUrl?: string; phone?: string },
|
||||
@Req() req: Request,
|
||||
) {
|
||||
const user = (req as RequestWithUser).user;
|
||||
return this.authService.updateProfile(user.sub, body);
|
||||
}
|
||||
}
|
||||
28
src/auth/auth.module.ts
Normal file
28
src/auth/auth.module.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { PassportModule } from '@nestjs/passport';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { AuthService } from './auth.service';
|
||||
import { JwtStrategy } from './strategies/jwt.strategy';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
PassportModule.register({ defaultStrategy: 'jwt' }),
|
||||
JwtModule.registerAsync({
|
||||
imports: [ConfigModule],
|
||||
inject: [ConfigService],
|
||||
useFactory: (config: ConfigService) => ({
|
||||
secret: config.get<string>('app.jwt.accessSecret'),
|
||||
signOptions: {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
expiresIn: config.get<any>('app.jwt.accessExpiresIn'),
|
||||
},
|
||||
}),
|
||||
}),
|
||||
],
|
||||
controllers: [AuthController],
|
||||
providers: [AuthService, JwtStrategy],
|
||||
exports: [AuthService, JwtModule, PassportModule],
|
||||
})
|
||||
export class AuthModule {}
|
||||
358
src/auth/auth.service.ts
Normal file
358
src/auth/auth.service.ts
Normal file
@@ -0,0 +1,358 @@
|
||||
import {
|
||||
Injectable,
|
||||
ConflictException,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { JwtService, JwtSignOptions } from '@nestjs/jwt';
|
||||
import * as argon2 from 'argon2';
|
||||
import { createHash, randomBytes } from 'node:crypto';
|
||||
import { PinoLogger } from 'nestjs-pino';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { EventsService } from '../events/events.service';
|
||||
import { VerificationService } from '../verification/verification.service';
|
||||
import { QrAuthService } from '../qr-auth/qr-auth.service';
|
||||
import { RegisterDto } from './dto/register.dto';
|
||||
import { LoginDto } from './dto/login.dto';
|
||||
import { JwtPayload } from './strategies/jwt.strategy';
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly jwtService: JwtService,
|
||||
private readonly configService: ConfigService,
|
||||
private readonly eventsService: EventsService,
|
||||
private readonly verificationService: VerificationService,
|
||||
private readonly qrAuthService: QrAuthService,
|
||||
private readonly logger: PinoLogger,
|
||||
) {
|
||||
this.logger.setContext(AuthService.name);
|
||||
}
|
||||
|
||||
async register(dto: RegisterDto, ip?: string, userAgent?: string) {
|
||||
const existing = await this.prisma.user.findUnique({
|
||||
where: { email: dto.email },
|
||||
});
|
||||
if (existing) {
|
||||
throw new ConflictException('Email already registered');
|
||||
}
|
||||
|
||||
const passwordHash = await argon2.hash(dto.password);
|
||||
|
||||
const user = await this.prisma.user.create({
|
||||
data: {
|
||||
email: dto.email,
|
||||
passwordHash,
|
||||
displayName: dto.displayName,
|
||||
},
|
||||
});
|
||||
|
||||
await this.prisma.auditLog.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
action: 'USER_REGISTERED',
|
||||
ip,
|
||||
userAgent,
|
||||
},
|
||||
});
|
||||
|
||||
this.eventsService.emit('auth.user.registered', {
|
||||
userId: user.id,
|
||||
email: user.email,
|
||||
});
|
||||
|
||||
this.logger.info({ userId: user.id }, 'User registered');
|
||||
|
||||
const tokens = await this.createSession(user.id, undefined, ip, userAgent);
|
||||
|
||||
return {
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
displayName: user.displayName,
|
||||
role: user.role,
|
||||
},
|
||||
...tokens,
|
||||
};
|
||||
}
|
||||
|
||||
async login(dto: LoginDto, ip?: string, userAgent?: string) {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { email: dto.email },
|
||||
});
|
||||
if (!user || !user.isActive || !user.passwordHash) {
|
||||
throw new UnauthorizedException('Invalid credentials');
|
||||
}
|
||||
|
||||
const valid = await argon2.verify(user.passwordHash, dto.password);
|
||||
if (!valid) {
|
||||
throw new UnauthorizedException('Invalid credentials');
|
||||
}
|
||||
|
||||
await this.prisma.auditLog.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
action: 'USER_LOGIN',
|
||||
ip,
|
||||
userAgent,
|
||||
},
|
||||
});
|
||||
|
||||
this.eventsService.emit('auth.user.logged_in', {
|
||||
userId: user.id,
|
||||
email: user.email,
|
||||
});
|
||||
|
||||
this.logger.info({ userId: user.id }, 'User logged in');
|
||||
|
||||
return this.createSession(user.id, undefined, ip, userAgent);
|
||||
}
|
||||
|
||||
async loginByEmailCode(
|
||||
email: string,
|
||||
code: string,
|
||||
ip?: string,
|
||||
userAgent?: string,
|
||||
) {
|
||||
await this.verificationService.verifyCode(email, code);
|
||||
|
||||
const user = await this.prisma.user.findUnique({ where: { email } });
|
||||
if (!user || !user.isActive) {
|
||||
throw new UnauthorizedException('User not found or deactivated');
|
||||
}
|
||||
|
||||
if (!user.emailVerifiedAt) {
|
||||
await this.prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { emailVerifiedAt: new Date() },
|
||||
});
|
||||
}
|
||||
|
||||
await this.prisma.auditLog.create({
|
||||
data: { userId: user.id, action: 'USER_LOGIN_EMAIL_CODE', ip, userAgent },
|
||||
});
|
||||
|
||||
return this.createSession(user.id, undefined, ip, userAgent);
|
||||
}
|
||||
|
||||
async loginByPhoneCode(
|
||||
phone: string,
|
||||
code: string,
|
||||
ip?: string,
|
||||
userAgent?: string,
|
||||
) {
|
||||
await this.verificationService.verifyCode(phone, code);
|
||||
|
||||
const user = await this.prisma.user.findUnique({ where: { phone } });
|
||||
if (!user || !user.isActive) {
|
||||
throw new UnauthorizedException('User not found or deactivated');
|
||||
}
|
||||
|
||||
if (!user.phoneVerifiedAt) {
|
||||
await this.prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { phoneVerifiedAt: new Date() },
|
||||
});
|
||||
}
|
||||
|
||||
await this.prisma.auditLog.create({
|
||||
data: { userId: user.id, action: 'USER_LOGIN_PHONE_CODE', ip, userAgent },
|
||||
});
|
||||
|
||||
return this.createSession(user.id, undefined, ip, userAgent);
|
||||
}
|
||||
|
||||
async sendEmailCode(email: string): Promise<void> {
|
||||
await this.verificationService.sendEmailCode(email);
|
||||
}
|
||||
|
||||
async sendPhoneCode(phone: string): Promise<void> {
|
||||
await this.verificationService.sendPhoneCode(phone);
|
||||
}
|
||||
|
||||
async initQrSession() {
|
||||
return this.qrAuthService.initSession();
|
||||
}
|
||||
|
||||
async pollQrSession(sessionId: string) {
|
||||
return this.qrAuthService.pollSession(sessionId);
|
||||
}
|
||||
|
||||
async refresh(refreshToken: string, ip?: string, userAgent?: string) {
|
||||
const tokenHash = this.hashToken(refreshToken);
|
||||
|
||||
const stored = await this.prisma.refreshToken.findUnique({
|
||||
where: { tokenHash },
|
||||
include: { session: true },
|
||||
});
|
||||
|
||||
if (!stored || stored.revoked || stored.expiresAt < new Date()) {
|
||||
throw new UnauthorizedException('Invalid or expired refresh token');
|
||||
}
|
||||
|
||||
if (!stored.session.isActive || stored.session.expiresAt < new Date()) {
|
||||
await this.prisma.refreshToken.update({
|
||||
where: { id: stored.id },
|
||||
data: { revoked: true },
|
||||
});
|
||||
throw new UnauthorizedException('Session expired');
|
||||
}
|
||||
|
||||
await this.prisma.refreshToken.update({
|
||||
where: { id: stored.id },
|
||||
data: { revoked: true },
|
||||
});
|
||||
|
||||
return this.createSession(
|
||||
stored.session.userId,
|
||||
stored.session.clientId ?? undefined,
|
||||
ip ?? stored.session.ip ?? undefined,
|
||||
userAgent ?? stored.session.userAgent ?? undefined,
|
||||
);
|
||||
}
|
||||
|
||||
async logout(refreshToken: string) {
|
||||
const tokenHash = this.hashToken(refreshToken);
|
||||
const stored = await this.prisma.refreshToken.findUnique({
|
||||
where: { tokenHash },
|
||||
include: { session: true },
|
||||
});
|
||||
|
||||
if (stored) {
|
||||
await this.prisma.refreshToken.update({
|
||||
where: { id: stored.id },
|
||||
data: { revoked: true },
|
||||
});
|
||||
|
||||
if (stored.session) {
|
||||
await this.prisma.session.update({
|
||||
where: { id: stored.session.id },
|
||||
data: { isActive: false },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async getProfile(userId: string) {
|
||||
return this.prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
phone: true,
|
||||
displayName: true,
|
||||
avatarUrl: true,
|
||||
role: true,
|
||||
emailVerifiedAt: true,
|
||||
phoneVerifiedAt: true,
|
||||
createdAt: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async updateProfile(
|
||||
userId: string,
|
||||
data: { displayName?: string; avatarUrl?: string; phone?: string },
|
||||
) {
|
||||
return this.prisma.user.update({
|
||||
where: { id: userId },
|
||||
data,
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
phone: true,
|
||||
displayName: true,
|
||||
avatarUrl: true,
|
||||
role: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async createSession(
|
||||
userId: string,
|
||||
clientId?: string,
|
||||
ip?: string,
|
||||
userAgent?: string,
|
||||
) {
|
||||
const now = new Date();
|
||||
const sessionTTL = 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
const session = await this.prisma.session.create({
|
||||
data: {
|
||||
userId,
|
||||
clientId,
|
||||
ip,
|
||||
userAgent,
|
||||
expiresAt: new Date(now.getTime() + sessionTTL),
|
||||
lastUsedAt: now,
|
||||
},
|
||||
});
|
||||
|
||||
const accessToken = await this.generateAccessToken(userId);
|
||||
const refreshToken = this.generateRefreshToken();
|
||||
const refreshTokenHash = this.hashToken(refreshToken);
|
||||
|
||||
const refreshExpiresIn = this.configService.get<string>(
|
||||
'app.jwt.refreshExpiresIn',
|
||||
)!;
|
||||
const refreshMs = this.parseDuration(refreshExpiresIn);
|
||||
|
||||
await this.prisma.refreshToken.create({
|
||||
data: {
|
||||
tokenHash: refreshTokenHash,
|
||||
sessionId: session.id,
|
||||
expiresAt: new Date(now.getTime() + refreshMs),
|
||||
},
|
||||
});
|
||||
|
||||
return { accessToken, refreshToken, sessionId: session.id };
|
||||
}
|
||||
|
||||
private async generateAccessToken(userId: string): Promise<string> {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { email: true, role: true },
|
||||
});
|
||||
|
||||
const options: JwtSignOptions = {
|
||||
secret: this.configService.get<string>('app.jwt.accessSecret'),
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
expiresIn: this.configService.get<any>('app.jwt.accessExpiresIn'),
|
||||
};
|
||||
return this.jwtService.signAsync(
|
||||
{
|
||||
sub: userId,
|
||||
email: user?.email ?? '',
|
||||
role: user?.role ?? 'USER',
|
||||
} satisfies JwtPayload,
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
private generateRefreshToken(): string {
|
||||
return randomBytes(64).toString('hex');
|
||||
}
|
||||
|
||||
private hashToken(token: string): string {
|
||||
return createHash('sha256').update(token).digest('hex');
|
||||
}
|
||||
|
||||
private parseDuration(duration: string): number {
|
||||
const match = duration.match(/^(\d+)([smhd])$/);
|
||||
if (!match) return 7 * 24 * 60 * 60 * 1000;
|
||||
const val = parseInt(match[1], 10);
|
||||
switch (match[2]) {
|
||||
case 's':
|
||||
return val * 1000;
|
||||
case 'm':
|
||||
return val * 60 * 1000;
|
||||
case 'h':
|
||||
return val * 60 * 60 * 1000;
|
||||
case 'd':
|
||||
return val * 24 * 60 * 60 * 1000;
|
||||
default:
|
||||
return 7 * 24 * 60 * 60 * 1000;
|
||||
}
|
||||
}
|
||||
}
|
||||
13
src/auth/dto/login.dto.ts
Normal file
13
src/auth/dto/login.dto.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { IsEmail, IsString, IsOptional } from 'class-validator';
|
||||
|
||||
export class LoginDto {
|
||||
@IsEmail()
|
||||
email!: string;
|
||||
|
||||
@IsString()
|
||||
password!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
clientId?: string;
|
||||
}
|
||||
23
src/auth/dto/register.dto.ts
Normal file
23
src/auth/dto/register.dto.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import {
|
||||
IsEmail,
|
||||
IsString,
|
||||
MinLength,
|
||||
MaxLength,
|
||||
IsOptional,
|
||||
} from 'class-validator';
|
||||
|
||||
export class RegisterDto {
|
||||
@IsEmail()
|
||||
email!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(8)
|
||||
@MaxLength(128)
|
||||
password!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(255)
|
||||
displayName?: string;
|
||||
}
|
||||
20
src/auth/strategies/jwt-auth.guard.ts
Normal file
20
src/auth/strategies/jwt-auth.guard.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { Injectable, ExecutionContext } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { IS_PUBLIC_KEY } from '../../common/decorators/public.decorator';
|
||||
|
||||
@Injectable()
|
||||
export class JwtAuthGuard extends AuthGuard('jwt') {
|
||||
constructor(private readonly reflector: Reflector) {
|
||||
super();
|
||||
}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean | Promise<boolean> {
|
||||
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
if (isPublic) return true;
|
||||
return super.canActivate(context) as boolean | Promise<boolean>;
|
||||
}
|
||||
}
|
||||
40
src/auth/strategies/jwt.strategy.ts
Normal file
40
src/auth/strategies/jwt.strategy.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
|
||||
export interface JwtPayload {
|
||||
sub: string;
|
||||
email: string;
|
||||
role: string;
|
||||
iat?: number;
|
||||
exp?: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||
constructor(
|
||||
configService: ConfigService,
|
||||
private readonly prisma: PrismaService,
|
||||
) {
|
||||
super({
|
||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
ignoreExpiration: false,
|
||||
secretOrKey: configService.get<string>('app.jwt.accessSecret')!,
|
||||
});
|
||||
}
|
||||
|
||||
async validate(payload: JwtPayload): Promise<JwtPayload> {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id: payload.sub },
|
||||
select: { id: true, isActive: true },
|
||||
});
|
||||
|
||||
if (!user || !user.isActive) {
|
||||
throw new UnauthorizedException('User is deactivated or not found');
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
}
|
||||
4
src/common/decorators/public.decorator.ts
Normal file
4
src/common/decorators/public.decorator.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
|
||||
export const IS_PUBLIC_KEY = 'isPublic';
|
||||
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
|
||||
5
src/common/decorators/roles.decorator.ts
Normal file
5
src/common/decorators/roles.decorator.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
import { Role } from '@prisma/client';
|
||||
|
||||
export const ROLES_KEY = 'roles';
|
||||
export const Roles = (...roles: Role[]) => SetMetadata(ROLES_KEY, roles);
|
||||
54
src/common/filters/all-exceptions.filter.ts
Normal file
54
src/common/filters/all-exceptions.filter.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import {
|
||||
ExceptionFilter,
|
||||
Catch,
|
||||
ArgumentsHost,
|
||||
HttpException,
|
||||
HttpStatus,
|
||||
} from '@nestjs/common';
|
||||
import { PinoLogger } from 'nestjs-pino';
|
||||
import type { Response, Request } from 'express';
|
||||
|
||||
@Catch()
|
||||
export class AllExceptionsFilter implements ExceptionFilter {
|
||||
constructor(private readonly logger: PinoLogger) {
|
||||
this.logger.setContext(AllExceptionsFilter.name);
|
||||
}
|
||||
|
||||
catch(exception: unknown, host: ArgumentsHost): void {
|
||||
const ctx = host.switchToHttp();
|
||||
const response = ctx.getResponse<Response>();
|
||||
const request = ctx.getRequest<Request>();
|
||||
|
||||
let status = HttpStatus.INTERNAL_SERVER_ERROR;
|
||||
let message = 'Internal server error';
|
||||
|
||||
if (exception instanceof HttpException) {
|
||||
status = exception.getStatus();
|
||||
const res = exception.getResponse();
|
||||
message =
|
||||
typeof res === 'string'
|
||||
? res
|
||||
: (((res as Record<string, unknown>)?.message as string) ??
|
||||
exception.message);
|
||||
} else if (exception instanceof Error) {
|
||||
message = exception.message;
|
||||
}
|
||||
|
||||
if (status === HttpStatus.INTERNAL_SERVER_ERROR) {
|
||||
this.logger.error(
|
||||
{
|
||||
err: exception instanceof Error ? exception : undefined,
|
||||
path: request.url,
|
||||
},
|
||||
message,
|
||||
);
|
||||
}
|
||||
|
||||
response.status(status).json({
|
||||
statusCode: status,
|
||||
message: Array.isArray(message) ? message : [message],
|
||||
timestamp: new Date().toISOString(),
|
||||
path: request.url,
|
||||
});
|
||||
}
|
||||
}
|
||||
24
src/common/guards/roles.guard.ts
Normal file
24
src/common/guards/roles.guard.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { Role } from '@prisma/client';
|
||||
import { ROLES_KEY } from '../decorators/roles.decorator';
|
||||
import { JwtPayload } from '../../auth/strategies/jwt.strategy';
|
||||
|
||||
@Injectable()
|
||||
export class RolesGuard implements CanActivate {
|
||||
constructor(private readonly reflector: Reflector) {}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const requiredRoles = this.reflector.getAllAndOverride<Role[]>(ROLES_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
if (!requiredRoles || requiredRoles.length === 0) return true;
|
||||
|
||||
const request = context.switchToHttp().getRequest<{ user?: JwtPayload }>();
|
||||
const user = request.user;
|
||||
if (!user) return false;
|
||||
|
||||
return requiredRoles.includes(user.role as Role);
|
||||
}
|
||||
}
|
||||
6
src/common/types.ts
Normal file
6
src/common/types.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import type { Request } from 'express';
|
||||
import type { JwtPayload } from '../auth/strategies/jwt.strategy';
|
||||
|
||||
export interface RequestWithUser extends Request {
|
||||
user: JwtPayload;
|
||||
}
|
||||
33
src/config/configuration.ts
Normal file
33
src/config/configuration.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { registerAs } from '@nestjs/config';
|
||||
|
||||
export default registerAs('app', () => ({
|
||||
nodeEnv: process.env.NODE_ENV,
|
||||
port: parseInt(process.env.PORT ?? '3000', 10),
|
||||
host: process.env.HOST ?? '0.0.0.0',
|
||||
trustProxy: parseInt(process.env.TRUST_PROXY ?? '1', 10),
|
||||
|
||||
jwt: {
|
||||
accessSecret: process.env.JWT_ACCESS_SECRET!,
|
||||
refreshSecret: process.env.JWT_REFRESH_SECRET!,
|
||||
accessExpiresIn: process.env.JWT_ACCESS_EXPIRES_IN ?? '15m',
|
||||
refreshExpiresIn: process.env.JWT_REFRESH_EXPIRES_IN ?? '7d',
|
||||
},
|
||||
|
||||
redis: {
|
||||
host: process.env.REDIS_HOST ?? 'localhost',
|
||||
port: parseInt(process.env.REDIS_PORT ?? '6379', 10),
|
||||
},
|
||||
|
||||
rabbitmq: {
|
||||
url: process.env.RABBITMQ_URL!,
|
||||
},
|
||||
|
||||
cors: {
|
||||
origins: (process.env.CORS_ORIGINS ?? 'http://localhost:3000').split(','),
|
||||
},
|
||||
|
||||
throttle: {
|
||||
ttl: parseInt(process.env.THROTTLE_TTL ?? '60', 10),
|
||||
limit: parseInt(process.env.THROTTLE_LIMIT ?? '10', 10),
|
||||
},
|
||||
}));
|
||||
56
src/config/env.validation.ts
Normal file
56
src/config/env.validation.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const envSchema = z.object({
|
||||
NODE_ENV: z
|
||||
.enum(['development', 'production', 'test'])
|
||||
.default('development'),
|
||||
PORT: z.coerce.number().int().positive().default(3000),
|
||||
HOST: z.string().default('0.0.0.0'),
|
||||
TRUST_PROXY: z.coerce.number().int().min(0).default(1),
|
||||
|
||||
JWT_ACCESS_SECRET: z.string().min(32),
|
||||
JWT_REFRESH_SECRET: z.string().min(32),
|
||||
JWT_ACCESS_EXPIRES_IN: z.string().default('15m'),
|
||||
JWT_REFRESH_EXPIRES_IN: z.string().default('7d'),
|
||||
|
||||
DATABASE_URL: z.string().url(),
|
||||
POSTGRES_USER: z.string().min(1),
|
||||
POSTGRES_PASSWORD: z.string().min(1),
|
||||
POSTGRES_DB: z.string().min(1),
|
||||
|
||||
REDIS_HOST: z.string().default('localhost'),
|
||||
REDIS_PORT: z.coerce.number().int().positive().default(6379),
|
||||
|
||||
RABBITMQ_USER: z.string().min(1),
|
||||
RABBITMQ_PASSWORD: z.string().min(1),
|
||||
RABBITMQ_HOST: z.string().default('localhost'),
|
||||
RABBITMQ_PORT: z.coerce.number().int().positive().default(5672),
|
||||
RABBITMQ_URL: z.string(),
|
||||
|
||||
CORS_ORIGINS: z.string().default('http://localhost:3000'),
|
||||
|
||||
THROTTLE_TTL: z.coerce.number().int().positive().default(60),
|
||||
THROTTLE_LIMIT: z.coerce.number().int().positive().default(10),
|
||||
|
||||
SMTP_HOST: z.string().default('localhost'),
|
||||
SMTP_PORT: z.coerce.number().int().positive().default(1025),
|
||||
SMTP_USER: z.string().default(''),
|
||||
SMTP_PASSWORD: z.string().default(''),
|
||||
SMTP_FROM: z.string().default('noreply@sso.local'),
|
||||
|
||||
SMS_PROVIDER: z.string().default('mock'),
|
||||
});
|
||||
|
||||
export type EnvConfig = z.infer<typeof envSchema>;
|
||||
|
||||
export function validateEnv(config: Record<string, unknown>): EnvConfig {
|
||||
const result = envSchema.safeParse(config);
|
||||
if (!result.success) {
|
||||
const errors = result.error.flatten().fieldErrors;
|
||||
const messages = Object.entries(errors)
|
||||
.map(([key, msgs]) => `${key}: ${msgs?.join(', ')}`)
|
||||
.join('\n ');
|
||||
throw new Error(`Config validation failed:\n ${messages}`);
|
||||
}
|
||||
return result.data;
|
||||
}
|
||||
9
src/events/events.module.ts
Normal file
9
src/events/events.module.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { EventsService } from './events.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [EventsService],
|
||||
exports: [EventsService],
|
||||
})
|
||||
export class EventsModule {}
|
||||
35
src/events/events.service.ts
Normal file
35
src/events/events.service.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import {
|
||||
ClientProxy,
|
||||
ClientProxyFactory,
|
||||
Transport,
|
||||
} from '@nestjs/microservices';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { PinoLogger } from 'nestjs-pino';
|
||||
|
||||
@Injectable()
|
||||
export class EventsService {
|
||||
private client: ClientProxy;
|
||||
|
||||
constructor(
|
||||
private readonly configService: ConfigService,
|
||||
private readonly logger: PinoLogger,
|
||||
) {
|
||||
this.logger.setContext(EventsService.name);
|
||||
this.client = ClientProxyFactory.create({
|
||||
transport: Transport.RMQ,
|
||||
options: {
|
||||
urls: [this.configService.get<string>('app.rabbitmq.url')!],
|
||||
queue: 'sso_events',
|
||||
queueOptions: { durable: true },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
emit(pattern: string, data: Record<string, unknown>): void {
|
||||
this.client.emit(pattern, data).subscribe({
|
||||
error: (err: Error) =>
|
||||
this.logger.error({ err, pattern }, 'Failed to emit event'),
|
||||
});
|
||||
}
|
||||
}
|
||||
9
src/mail/mail.module.ts
Normal file
9
src/mail/mail.module.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { MailService } from './mail.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [MailService],
|
||||
exports: [MailService],
|
||||
})
|
||||
export class MailModule {}
|
||||
44
src/mail/mail.service.ts
Normal file
44
src/mail/mail.service.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import * as nodemailer from 'nodemailer';
|
||||
import { PinoLogger } from 'nestjs-pino';
|
||||
|
||||
@Injectable()
|
||||
export class MailService {
|
||||
private transporter: nodemailer.Transporter;
|
||||
|
||||
constructor(
|
||||
private readonly configService: ConfigService,
|
||||
private readonly logger: PinoLogger,
|
||||
) {
|
||||
this.logger.setContext(MailService.name);
|
||||
this.transporter = nodemailer.createTransport({
|
||||
host: this.configService.get<string>('SMTP_HOST'),
|
||||
port: this.configService.get<number>('SMTP_PORT'),
|
||||
secure: false,
|
||||
auth: this.configService.get<string>('SMTP_USER')
|
||||
? {
|
||||
user: this.configService.get<string>('SMTP_USER'),
|
||||
pass: this.configService.get<string>('SMTP_PASSWORD'),
|
||||
}
|
||||
: undefined,
|
||||
tls: { rejectUnauthorized: false },
|
||||
});
|
||||
}
|
||||
|
||||
async sendCode(to: string, code: string, purpose: string): Promise<void> {
|
||||
const from = this.configService.get<string>('SMTP_FROM')!;
|
||||
try {
|
||||
await this.transporter.sendMail({
|
||||
from,
|
||||
to,
|
||||
subject: `SSO: код ${purpose === 'AUTH' ? 'для входа' : 'подтверждения'}`,
|
||||
text: `Ваш код: ${code}\nДействителен 5 минут.`,
|
||||
html: `<p>Ваш код: <strong>${code}</strong></p><p>Действителен 5 минут.</p>`,
|
||||
});
|
||||
this.logger.info({ to, purpose }, 'Verification code sent via email');
|
||||
} catch (err: unknown) {
|
||||
this.logger.error({ err: err as Error, to }, 'Failed to send email');
|
||||
}
|
||||
}
|
||||
}
|
||||
71
src/main.ts
Normal file
71
src/main.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { ValidationPipe } from '@nestjs/common';
|
||||
import { Logger } from 'nestjs-pino';
|
||||
import { NestExpressApplication } from '@nestjs/platform-express';
|
||||
import * as helmet from 'helmet';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import { join } from 'node:path';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { AppModule } from './app.module';
|
||||
|
||||
async function bootstrap(): Promise<void> {
|
||||
const app = await NestFactory.create<NestExpressApplication>(AppModule, {
|
||||
bufferLogs: true,
|
||||
});
|
||||
|
||||
const logger = app.get(Logger);
|
||||
app.useLogger(logger);
|
||||
|
||||
app.use(helmet.default({ contentSecurityPolicy: false }));
|
||||
app.use(cookieParser());
|
||||
|
||||
app.enableCors({
|
||||
origin: (process.env.CORS_ORIGINS ?? 'http://localhost:3000').split(','),
|
||||
credentials: true,
|
||||
});
|
||||
|
||||
const viewsPath = join(process.cwd(), 'views');
|
||||
if (existsSync(viewsPath)) {
|
||||
app.setBaseViewsDir(viewsPath);
|
||||
app.setViewEngine('ejs');
|
||||
}
|
||||
|
||||
const publicPath = join(process.cwd(), 'public');
|
||||
if (existsSync(publicPath)) {
|
||||
app.useStaticAssets(publicPath);
|
||||
}
|
||||
|
||||
app.setGlobalPrefix('api', {
|
||||
exclude: [
|
||||
'/',
|
||||
'/login',
|
||||
'/register',
|
||||
'/profile',
|
||||
'/admin',
|
||||
'/admin/users',
|
||||
'/admin/clients',
|
||||
'/metrics',
|
||||
],
|
||||
});
|
||||
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
whitelist: true,
|
||||
forbidNonWhitelisted: true,
|
||||
transform: true,
|
||||
}),
|
||||
);
|
||||
|
||||
const trustProxy = parseInt(process.env.TRUST_PROXY ?? '1', 10);
|
||||
|
||||
const instance: any = app.getHttpAdapter().getInstance();
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
|
||||
instance.set('trust proxy', trustProxy);
|
||||
|
||||
const port = parseInt(process.env.PORT ?? '3000', 10);
|
||||
const host = process.env.HOST ?? '0.0.0.0';
|
||||
|
||||
await app.listen(port, host);
|
||||
logger.log(`SSO service listening on http://${host}:${port}`);
|
||||
}
|
||||
void bootstrap();
|
||||
33
src/metrics/metrics.module.ts
Normal file
33
src/metrics/metrics.module.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import {
|
||||
PrometheusModule,
|
||||
makeCounterProvider,
|
||||
makeGaugeProvider,
|
||||
} from '@willsoto/nestjs-prometheus';
|
||||
import { MetricsService } from './metrics.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
PrometheusModule.register({
|
||||
path: '/metrics',
|
||||
defaultMetrics: { enabled: true },
|
||||
}),
|
||||
],
|
||||
providers: [
|
||||
MetricsService,
|
||||
makeCounterProvider({
|
||||
name: 'sso_logins_total',
|
||||
help: 'Total number of successful logins',
|
||||
}),
|
||||
makeCounterProvider({
|
||||
name: 'sso_logins_failed_total',
|
||||
help: 'Total number of failed login attempts',
|
||||
}),
|
||||
makeGaugeProvider({
|
||||
name: 'sso_active_sessions',
|
||||
help: 'Number of active sessions',
|
||||
}),
|
||||
],
|
||||
exports: [MetricsService],
|
||||
})
|
||||
export class MetricsModule {}
|
||||
27
src/metrics/metrics.service.ts
Normal file
27
src/metrics/metrics.service.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectMetric } from '@willsoto/nestjs-prometheus';
|
||||
import { Counter, Gauge } from 'prom-client';
|
||||
|
||||
@Injectable()
|
||||
export class MetricsService {
|
||||
constructor(
|
||||
@InjectMetric('sso_logins_total')
|
||||
private readonly loginsCounter: Counter<string>,
|
||||
@InjectMetric('sso_logins_failed_total')
|
||||
private readonly failedLoginsCounter: Counter<string>,
|
||||
@InjectMetric('sso_active_sessions')
|
||||
private readonly activeSessionsGauge: Gauge<string>,
|
||||
) {}
|
||||
|
||||
incrementLogins(): void {
|
||||
this.loginsCounter.inc();
|
||||
}
|
||||
|
||||
incrementFailedLogins(): void {
|
||||
this.failedLoginsCounter.inc();
|
||||
}
|
||||
|
||||
setActiveSessions(count: number): void {
|
||||
this.activeSessionsGauge.set(count);
|
||||
}
|
||||
}
|
||||
28
src/oidc/dto/authorize.dto.ts
Normal file
28
src/oidc/dto/authorize.dto.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { IsString, IsOptional } from 'class-validator';
|
||||
|
||||
export class AuthorizeDto {
|
||||
@IsString()
|
||||
response_type!: string;
|
||||
|
||||
@IsString()
|
||||
client_id!: string;
|
||||
|
||||
@IsString()
|
||||
redirect_uri!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
scope?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
state?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
code_challenge?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
code_challenge_method?: string;
|
||||
}
|
||||
30
src/oidc/dto/token.dto.ts
Normal file
30
src/oidc/dto/token.dto.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { IsString, IsOptional } from 'class-validator';
|
||||
|
||||
export class TokenDto {
|
||||
@IsString()
|
||||
grant_type!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
code?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
refresh_token?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
client_id?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
client_secret?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
redirect_uri?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
code_verifier?: string;
|
||||
}
|
||||
84
src/oidc/oidc.controller.ts
Normal file
84
src/oidc/oidc.controller.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Body,
|
||||
Query,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Headers,
|
||||
Req,
|
||||
Res,
|
||||
} from '@nestjs/common';
|
||||
import type { Request, Response } from 'express';
|
||||
import { Public } from '../common/decorators/public.decorator';
|
||||
import { OidcService } from './oidc.service';
|
||||
|
||||
@Controller()
|
||||
export class OidcController {
|
||||
constructor(private readonly oidcService: OidcService) {}
|
||||
|
||||
@Public()
|
||||
@Get('authorize')
|
||||
async authorize(
|
||||
@Query('response_type') responseType: string,
|
||||
@Query('client_id') clientId: string,
|
||||
@Query('redirect_uri') redirectUri: string,
|
||||
@Query('scope') scope: string,
|
||||
@Query('state') state: string,
|
||||
@Query('code_challenge') codeChallenge: string,
|
||||
@Query('code_challenge_method') codeChallengeMethod: string,
|
||||
@Res() res: Response,
|
||||
) {
|
||||
if (responseType !== 'code') {
|
||||
return res.status(400).json({ error: 'unsupported_response_type' });
|
||||
}
|
||||
|
||||
const result = await this.oidcService.authorize(
|
||||
clientId,
|
||||
redirectUri,
|
||||
scope || 'openid profile',
|
||||
state,
|
||||
codeChallenge,
|
||||
codeChallengeMethod,
|
||||
);
|
||||
|
||||
return res.redirect(result.redirectUrl);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post('token')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async token(@Body() body: Record<string, string>) {
|
||||
return this.oidcService.token(
|
||||
body.grant_type,
|
||||
body.code,
|
||||
body.refresh_token,
|
||||
body.client_id,
|
||||
body.client_secret,
|
||||
body.redirect_uri,
|
||||
body.code_verifier,
|
||||
);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Get('userinfo')
|
||||
async userinfo(@Req() req: Request) {
|
||||
const authHeader = req.headers.authorization;
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
throw new (await import('@nestjs/common')).UnauthorizedException(
|
||||
'Missing token',
|
||||
);
|
||||
}
|
||||
const token = authHeader.slice(7);
|
||||
return this.oidcService.userinfo(token);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post('revoke')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async revoke(@Body('token') token: string) {
|
||||
await this.oidcService.revoke(token);
|
||||
return { revoked: true };
|
||||
}
|
||||
}
|
||||
26
src/oidc/oidc.module.ts
Normal file
26
src/oidc/oidc.module.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { OidcController } from './oidc.controller';
|
||||
import { OidcService } from './oidc.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
AuthModule,
|
||||
JwtModule.registerAsync({
|
||||
imports: [ConfigModule],
|
||||
inject: [ConfigService],
|
||||
useFactory: (config: ConfigService) => ({
|
||||
secret: config.get<string>('app.jwt.accessSecret'),
|
||||
signOptions: {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
expiresIn: config.get<any>('app.jwt.accessExpiresIn'),
|
||||
},
|
||||
}),
|
||||
}),
|
||||
],
|
||||
controllers: [OidcController],
|
||||
providers: [OidcService],
|
||||
})
|
||||
export class OidcModule {}
|
||||
206
src/oidc/oidc.service.ts
Normal file
206
src/oidc/oidc.service.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
import {
|
||||
Injectable,
|
||||
BadRequestException,
|
||||
UnauthorizedException,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { JwtService, JwtSignOptions } from '@nestjs/jwt';
|
||||
import { createHash, randomBytes } from 'node:crypto';
|
||||
import { PinoLogger } from 'nestjs-pino';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { AuthService } from '../auth/auth.service';
|
||||
|
||||
@Injectable()
|
||||
export class OidcService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly jwtService: JwtService,
|
||||
private readonly configService: ConfigService,
|
||||
private readonly authService: AuthService,
|
||||
private readonly logger: PinoLogger,
|
||||
) {
|
||||
this.logger.setContext(OidcService.name);
|
||||
}
|
||||
|
||||
async authorize(
|
||||
clientId: string,
|
||||
redirectUri: string,
|
||||
scope: string,
|
||||
state?: string,
|
||||
codeChallenge?: string,
|
||||
codeChallengeMethod?: string,
|
||||
) {
|
||||
const client = await this.prisma.client.findUnique({
|
||||
where: { clientId },
|
||||
});
|
||||
|
||||
if (!client) {
|
||||
throw new BadRequestException('Invalid client_id');
|
||||
}
|
||||
|
||||
if (!client.redirectUris.includes(redirectUri)) {
|
||||
throw new BadRequestException('Invalid redirect_uri');
|
||||
}
|
||||
|
||||
const authorizationCode = randomBytes(32).toString('hex');
|
||||
const codeHash = createHash('sha256')
|
||||
.update(authorizationCode)
|
||||
.digest('hex');
|
||||
|
||||
const redirectUrl = new URL(redirectUri);
|
||||
redirectUrl.searchParams.set('code', authorizationCode);
|
||||
redirectUrl.searchParams.set('state', state ?? '');
|
||||
|
||||
if (codeChallenge && codeChallengeMethod) {
|
||||
redirectUrl.searchParams.set('code_challenge', codeChallenge);
|
||||
redirectUrl.searchParams.set(
|
||||
'code_challenge_method',
|
||||
codeChallengeMethod,
|
||||
);
|
||||
}
|
||||
|
||||
return { redirectUrl: redirectUrl.toString(), codeHash };
|
||||
}
|
||||
|
||||
async token(
|
||||
grantType: string,
|
||||
code?: string,
|
||||
refreshToken?: string,
|
||||
clientId?: string,
|
||||
clientSecret?: string,
|
||||
redirectUri?: string,
|
||||
codeVerifier?: string,
|
||||
) {
|
||||
switch (grantType) {
|
||||
case 'authorization_code':
|
||||
return this.tokenByAuthCode(
|
||||
code!,
|
||||
clientId!,
|
||||
clientSecret,
|
||||
redirectUri,
|
||||
codeVerifier,
|
||||
);
|
||||
case 'refresh_token':
|
||||
return this.authService.refresh(refreshToken!);
|
||||
case 'client_credentials':
|
||||
return this.tokenByClientCredentials(clientId!, clientSecret!);
|
||||
default:
|
||||
throw new BadRequestException('Unsupported grant_type');
|
||||
}
|
||||
}
|
||||
|
||||
async userinfo(accessToken: string) {
|
||||
try {
|
||||
const payload = await this.jwtService.verifyAsync<{ sub: string }>(
|
||||
accessToken,
|
||||
{
|
||||
secret: this.configService.get<string>('app.jwt.accessSecret'),
|
||||
},
|
||||
);
|
||||
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id: payload.sub },
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
displayName: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new NotFoundException('User not found');
|
||||
}
|
||||
|
||||
return {
|
||||
sub: user.id,
|
||||
email: user.email,
|
||||
name: user.displayName,
|
||||
};
|
||||
} catch {
|
||||
throw new UnauthorizedException('Invalid or expired access token');
|
||||
}
|
||||
}
|
||||
|
||||
async revoke(token: string) {
|
||||
await this.authService.logout(token);
|
||||
}
|
||||
|
||||
private async tokenByAuthCode(
|
||||
code: string,
|
||||
clientId: string,
|
||||
clientSecret?: string,
|
||||
redirectUri?: string,
|
||||
codeVerifier?: string,
|
||||
) {
|
||||
const client = await this.prisma.client.findUnique({
|
||||
where: { clientId },
|
||||
});
|
||||
|
||||
if (!client) {
|
||||
throw new BadRequestException('Invalid client_id');
|
||||
}
|
||||
|
||||
if (client.isConfidential) {
|
||||
if (!clientSecret || client.clientSecret !== clientSecret) {
|
||||
throw new UnauthorizedException('Invalid client_secret');
|
||||
}
|
||||
}
|
||||
|
||||
if (redirectUri && !client.redirectUris.includes(redirectUri)) {
|
||||
throw new BadRequestException('Invalid redirect_uri');
|
||||
}
|
||||
|
||||
if (codeVerifier) {
|
||||
void createHash('sha256').update(codeVerifier).digest('base64url');
|
||||
}
|
||||
|
||||
const refreshToken = randomBytes(64).toString('hex');
|
||||
|
||||
const options = {
|
||||
secret: this.configService.get<string>('app.jwt.accessSecret'),
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
expiresIn: this.configService.get<any>('app.jwt.accessExpiresIn'),
|
||||
} satisfies JwtSignOptions;
|
||||
const accessToken = await this.jwtService.signAsync(
|
||||
{ sub: clientId, client_id: clientId },
|
||||
options,
|
||||
);
|
||||
|
||||
return {
|
||||
access_token: accessToken,
|
||||
token_type: 'Bearer',
|
||||
expires_in: 900,
|
||||
refresh_token: refreshToken,
|
||||
};
|
||||
}
|
||||
|
||||
private async tokenByClientCredentials(
|
||||
clientId: string,
|
||||
clientSecret: string,
|
||||
) {
|
||||
const client = await this.prisma.client.findUnique({
|
||||
where: { clientId },
|
||||
});
|
||||
|
||||
if (!client || client.clientSecret !== clientSecret) {
|
||||
throw new UnauthorizedException('Invalid client credentials');
|
||||
}
|
||||
|
||||
const options = {
|
||||
secret: this.configService.get<string>('app.jwt.accessSecret'),
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
expiresIn: this.configService.get<any>('app.jwt.accessExpiresIn'),
|
||||
} satisfies JwtSignOptions;
|
||||
const accessToken = await this.jwtService.signAsync(
|
||||
{ sub: clientId, client_id: clientId },
|
||||
options,
|
||||
);
|
||||
|
||||
return {
|
||||
access_token: accessToken,
|
||||
token_type: 'Bearer',
|
||||
expires_in: 900,
|
||||
};
|
||||
}
|
||||
}
|
||||
9
src/prisma/prisma.module.ts
Normal file
9
src/prisma/prisma.module.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { PrismaService } from './prisma.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [PrismaService],
|
||||
exports: [PrismaService],
|
||||
})
|
||||
export class PrismaModule {}
|
||||
24
src/prisma/prisma.service.ts
Normal file
24
src/prisma/prisma.service.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { PrismaPg } from '@prisma/adapter-pg';
|
||||
import pg from 'pg';
|
||||
|
||||
@Injectable()
|
||||
export class PrismaService
|
||||
extends PrismaClient
|
||||
implements OnModuleInit, OnModuleDestroy
|
||||
{
|
||||
constructor() {
|
||||
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });
|
||||
const adapter = new PrismaPg(pool);
|
||||
super({ adapter });
|
||||
}
|
||||
|
||||
async onModuleInit(): Promise<void> {
|
||||
await this.$connect();
|
||||
}
|
||||
|
||||
async onModuleDestroy(): Promise<void> {
|
||||
await this.$disconnect();
|
||||
}
|
||||
}
|
||||
4
src/profile/profile.module.ts
Normal file
4
src/profile/profile.module.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
@Module({})
|
||||
export class ProfileModule {}
|
||||
8
src/qr-auth/qr-auth.module.ts
Normal file
8
src/qr-auth/qr-auth.module.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { QrAuthService } from './qr-auth.service';
|
||||
|
||||
@Module({
|
||||
providers: [QrAuthService],
|
||||
exports: [QrAuthService],
|
||||
})
|
||||
export class QrAuthModule {}
|
||||
145
src/qr-auth/qr-auth.service.ts
Normal file
145
src/qr-auth/qr-auth.service.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import * as qrcode from 'qrcode';
|
||||
import { PinoLogger } from 'nestjs-pino';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { AuthService } from '../auth/auth.service';
|
||||
import { JwtPayload } from '../auth/strategies/jwt.strategy';
|
||||
|
||||
@Injectable()
|
||||
export class QrAuthService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly jwtService: JwtService,
|
||||
private readonly configService: ConfigService,
|
||||
private readonly authService: AuthService,
|
||||
private readonly logger: PinoLogger,
|
||||
) {
|
||||
this.logger.setContext(QrAuthService.name);
|
||||
}
|
||||
|
||||
async initSession(): Promise<{
|
||||
sessionId: string;
|
||||
qrDataUrl: string;
|
||||
qrToken: string;
|
||||
}> {
|
||||
const qrSessionId = uuid();
|
||||
const qrToken = uuid();
|
||||
|
||||
const qrSession = await this.prisma.qrSession.create({
|
||||
data: {
|
||||
sessionId: qrSessionId,
|
||||
qrData: qrToken,
|
||||
expiresAt: new Date(Date.now() + 2 * 60 * 1000),
|
||||
},
|
||||
});
|
||||
|
||||
const qrPayload = JSON.stringify({
|
||||
type: 'sso_qr',
|
||||
sessionId: qrSessionId,
|
||||
token: qrToken,
|
||||
server: this.configService.get<string>('HOST')!,
|
||||
});
|
||||
|
||||
const qrDataUrl = await qrcode.toDataURL(qrPayload);
|
||||
|
||||
return { sessionId: qrSession.sessionId, qrDataUrl, qrToken };
|
||||
}
|
||||
|
||||
async scanSession(
|
||||
sessionId: string,
|
||||
qrToken: string,
|
||||
userPayload: JwtPayload,
|
||||
): Promise<void> {
|
||||
const session = await this.prisma.qrSession.findUnique({
|
||||
where: { sessionId },
|
||||
});
|
||||
|
||||
if (
|
||||
!session ||
|
||||
session.status !== 'PENDING' ||
|
||||
session.expiresAt < new Date()
|
||||
) {
|
||||
throw new UnauthorizedException('Invalid or expired QR session');
|
||||
}
|
||||
|
||||
if (session.qrData !== qrToken) {
|
||||
throw new UnauthorizedException('QR token mismatch');
|
||||
}
|
||||
|
||||
await this.prisma.qrSession.update({
|
||||
where: { id: session.id },
|
||||
data: {
|
||||
userId: userPayload.sub,
|
||||
status: 'SCANNED',
|
||||
deviceInfo: 'Mobile app',
|
||||
},
|
||||
});
|
||||
|
||||
this.logger.info(
|
||||
{ sessionId, userId: userPayload.sub },
|
||||
'QR session scanned',
|
||||
);
|
||||
}
|
||||
|
||||
async confirmSession(sessionId: string, userId: string): Promise<void> {
|
||||
const session = await this.prisma.qrSession.findUnique({
|
||||
where: { sessionId },
|
||||
});
|
||||
|
||||
if (!session || session.userId !== userId || session.status !== 'SCANNED') {
|
||||
throw new UnauthorizedException('Cannot confirm QR session');
|
||||
}
|
||||
|
||||
await this.prisma.qrSession.update({
|
||||
where: { id: session.id },
|
||||
data: { status: 'CONFIRMED', confirmedAt: new Date() },
|
||||
});
|
||||
|
||||
this.logger.info({ sessionId, userId }, 'QR session confirmed');
|
||||
}
|
||||
|
||||
async pollSession(
|
||||
sessionId: string,
|
||||
): Promise<{ accessToken?: string; status: string }> {
|
||||
const session = await this.prisma.qrSession.findUnique({
|
||||
where: { sessionId },
|
||||
});
|
||||
|
||||
if (!session) {
|
||||
return { status: 'EXPIRED' };
|
||||
}
|
||||
|
||||
if (session.status === 'CONFIRMED' && session.userId) {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id: session.userId },
|
||||
select: { id: true, email: true, role: true },
|
||||
});
|
||||
if (!user) return { status: 'ERROR' };
|
||||
|
||||
const accessToken = await this.jwtService.signAsync(
|
||||
{
|
||||
sub: user.id,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
} satisfies JwtPayload,
|
||||
{
|
||||
secret: this.configService.get<string>('app.jwt.accessSecret'),
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
expiresIn: this.configService.get<any>('app.jwt.accessExpiresIn'),
|
||||
},
|
||||
);
|
||||
|
||||
await this.prisma.qrSession.update({
|
||||
where: { id: session.id },
|
||||
data: { status: 'EXPIRED' },
|
||||
});
|
||||
|
||||
return { accessToken, status: 'CONFIRMED' };
|
||||
}
|
||||
|
||||
return { status: session.status };
|
||||
}
|
||||
}
|
||||
9
src/verification/verification.module.ts
Normal file
9
src/verification/verification.module.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { VerificationService } from './verification.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [VerificationService],
|
||||
exports: [VerificationService],
|
||||
})
|
||||
export class VerificationModule {}
|
||||
72
src/verification/verification.service.ts
Normal file
72
src/verification/verification.service.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { Injectable, BadRequestException } from '@nestjs/common';
|
||||
import { randomInt } from 'node:crypto';
|
||||
import { PinoLogger } from 'nestjs-pino';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { MailService } from '../mail/mail.service';
|
||||
|
||||
@Injectable()
|
||||
export class VerificationService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly mailService: MailService,
|
||||
private readonly logger: PinoLogger,
|
||||
) {
|
||||
this.logger.setContext(VerificationService.name);
|
||||
}
|
||||
|
||||
generateCode(): string {
|
||||
return randomInt(100000, 999999).toString();
|
||||
}
|
||||
|
||||
async sendEmailCode(email: string): Promise<void> {
|
||||
const code = this.generateCode();
|
||||
await this.prisma.verificationCode.create({
|
||||
data: {
|
||||
target: email,
|
||||
channel: 'EMAIL',
|
||||
code,
|
||||
purpose: 'AUTH',
|
||||
expiresAt: new Date(Date.now() + 5 * 60 * 1000),
|
||||
},
|
||||
});
|
||||
await this.mailService.sendCode(email, code, 'AUTH');
|
||||
this.logger.info({ email }, 'Email verification code sent');
|
||||
}
|
||||
|
||||
async sendPhoneCode(phone: string): Promise<void> {
|
||||
const code = this.generateCode();
|
||||
await this.prisma.verificationCode.create({
|
||||
data: {
|
||||
target: phone,
|
||||
channel: 'SMS',
|
||||
code,
|
||||
purpose: 'AUTH',
|
||||
expiresAt: new Date(Date.now() + 5 * 60 * 1000),
|
||||
},
|
||||
});
|
||||
this.logger.info({ phone }, 'SMS verification code would be sent (mock)');
|
||||
}
|
||||
|
||||
async verifyCode(target: string, code: string): Promise<boolean> {
|
||||
const record = await this.prisma.verificationCode.findFirst({
|
||||
where: {
|
||||
target,
|
||||
code,
|
||||
usedAt: null,
|
||||
expiresAt: { gt: new Date() },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
if (!record) {
|
||||
throw new BadRequestException('Invalid or expired code');
|
||||
}
|
||||
|
||||
await this.prisma.verificationCode.update({
|
||||
where: { id: record.id },
|
||||
data: { usedAt: new Date() },
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
135
src/web/web.controller.ts
Normal file
135
src/web/web.controller.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import { Controller, Get, Render, Req, UseGuards } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import type { Request } from 'express';
|
||||
import { Public } from '../common/decorators/public.decorator';
|
||||
import { Roles } from '../common/decorators/roles.decorator';
|
||||
import { RolesGuard } from '../common/guards/roles.guard';
|
||||
import { RequestWithUser } from '../common/types';
|
||||
import { Role } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
@Controller()
|
||||
export class WebController {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
@Public()
|
||||
@Get('login')
|
||||
@Render('auth/login')
|
||||
loginPage() {
|
||||
return {};
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Get('register')
|
||||
@Render('auth/register')
|
||||
registerPage() {
|
||||
return {};
|
||||
}
|
||||
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@Get('profile')
|
||||
@Render('profile/index')
|
||||
async profilePage(@Req() req: Request) {
|
||||
const user = (req as RequestWithUser).user;
|
||||
const profile = await this.prisma.user.findUnique({
|
||||
where: { id: user.sub },
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
phone: true,
|
||||
displayName: true,
|
||||
avatarUrl: true,
|
||||
role: true,
|
||||
emailVerifiedAt: true,
|
||||
phoneVerifiedAt: true,
|
||||
createdAt: true,
|
||||
},
|
||||
});
|
||||
return { user: profile };
|
||||
}
|
||||
|
||||
@UseGuards(AuthGuard('jwt'), RolesGuard)
|
||||
@Roles(Role.ADMIN)
|
||||
@Get('admin')
|
||||
@Render('admin/users')
|
||||
async adminUsers(@Req() req: Request) {
|
||||
const jwtUser = (req as RequestWithUser).user;
|
||||
const users = await this.prisma.user.findMany({
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 50,
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
displayName: true,
|
||||
role: true,
|
||||
isActive: true,
|
||||
createdAt: true,
|
||||
_count: { select: { sessions: true } },
|
||||
},
|
||||
});
|
||||
const clients = await this.prisma.client.count();
|
||||
const totalUsers = await this.prisma.user.count();
|
||||
const activeSessions = await this.prisma.session.count({
|
||||
where: { isActive: true },
|
||||
});
|
||||
return {
|
||||
user: { email: jwtUser.email, role: jwtUser.role },
|
||||
users,
|
||||
stats: { users: totalUsers, clients, activeSessions },
|
||||
};
|
||||
}
|
||||
|
||||
@UseGuards(AuthGuard('jwt'), RolesGuard)
|
||||
@Roles(Role.ADMIN)
|
||||
@Get('admin/users')
|
||||
@Render('admin/users')
|
||||
async adminUsersPage(@Req() req: Request) {
|
||||
const jwtUser = (req as RequestWithUser).user;
|
||||
const users = await this.prisma.user.findMany({
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 50,
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
displayName: true,
|
||||
role: true,
|
||||
isActive: true,
|
||||
createdAt: true,
|
||||
_count: { select: { sessions: true } },
|
||||
},
|
||||
});
|
||||
const totalUsers = await this.prisma.user.count();
|
||||
const clients = await this.prisma.client.count();
|
||||
const activeSessions = await this.prisma.session.count({
|
||||
where: { isActive: true },
|
||||
});
|
||||
return {
|
||||
user: { email: jwtUser.email, role: jwtUser.role },
|
||||
users,
|
||||
stats: { users: totalUsers, clients, activeSessions },
|
||||
};
|
||||
}
|
||||
|
||||
@UseGuards(AuthGuard('jwt'), RolesGuard)
|
||||
@Roles(Role.ADMIN)
|
||||
@Get('admin/clients')
|
||||
@Render('admin/clients')
|
||||
async adminClientsPage(@Req() req: Request) {
|
||||
const jwtUser = (req as RequestWithUser).user;
|
||||
const clients = await this.prisma.client.findMany({
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 50,
|
||||
include: { owner: { select: { id: true, email: true } } },
|
||||
});
|
||||
const totalUsers = await this.prisma.user.count();
|
||||
const totalClients = await this.prisma.client.count();
|
||||
const activeSessions = await this.prisma.session.count({
|
||||
where: { isActive: true },
|
||||
});
|
||||
return {
|
||||
user: { email: jwtUser.email, role: jwtUser.role },
|
||||
clients,
|
||||
stats: { users: totalUsers, clients: totalClients, activeSessions },
|
||||
};
|
||||
}
|
||||
}
|
||||
7
src/web/web.module.ts
Normal file
7
src/web/web.module.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { WebController } from './web.controller';
|
||||
|
||||
@Module({
|
||||
controllers: [WebController],
|
||||
})
|
||||
export class WebModule {}
|
||||
Reference in New Issue
Block a user