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

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