third commit

This commit is contained in:
Дмитрий Мамедов
2026-06-10 17:01:10 +03:00
parent d393cbe1db
commit 00919968f2
21 changed files with 721 additions and 274 deletions

View File

@@ -7,18 +7,20 @@ import {
Body,
Param,
Query,
UseGuards,
Req,
ParseUUIDPipe,
ParseIntPipe,
DefaultValuePipe,
} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import type { Request } from 'express';
import { Role } from '@prisma/client';
import { Roles } from '../common/decorators/roles.decorator';
import { RolesGuard } from '../common/guards/roles.guard';
import { RequestWithUser } from '../common/types';
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) {}
@@ -30,49 +32,73 @@ export class AdminController {
@Get('users')
async listUsers(
@Query('page') page?: string,
@Query('limit') limit?: string,
@Query(
'page',
new DefaultValuePipe(1),
new ParseIntPipe({ optional: true }),
)
page?: number,
@Query(
'limit',
new DefaultValuePipe(20),
new ParseIntPipe({ optional: true }),
)
limit?: number,
) {
return this.adminService.listUsers(
page ? parseInt(page, 10) : 1,
limit ? parseInt(limit, 10) : 20,
);
return this.adminService.listUsers(page ?? 1, limit ?? 20);
}
@Get('users/:id')
async getUser(@Param('id') id: string) {
async getUser(@Param('id', ParseUUIDPipe) id: string) {
return this.adminService.getUser(id);
}
@Post('users')
async createUser(@Body() dto: CreateUserDto) {
return this.adminService.createUser(dto);
async createUser(@Body() dto: CreateUserDto, @Req() req: Request) {
const actorId = (req as RequestWithUser).user?.sub;
return this.adminService.createUser(dto, actorId);
}
@Put('users/:id')
async updateUser(@Param('id') id: string, @Body() dto: UpdateUserDto) {
return this.adminService.updateUser(id, dto);
async updateUser(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdateUserDto,
@Req() req: Request,
) {
const actorId = (req as RequestWithUser).user?.sub;
return this.adminService.updateUser(id, dto, actorId);
}
@Delete('users/:id')
async deleteUser(@Param('id') id: string) {
await this.adminService.deleteUser(id);
async deleteUser(
@Param('id', ParseUUIDPipe) id: string,
@Req() req: Request,
) {
const actorId = (req as RequestWithUser).user?.sub;
await this.adminService.deleteUser(id, actorId);
return { deleted: true };
}
@Get('clients')
async listClients(
@Query('page') page?: string,
@Query('limit') limit?: string,
@Query(
'page',
new DefaultValuePipe(1),
new ParseIntPipe({ optional: true }),
)
page?: number,
@Query(
'limit',
new DefaultValuePipe(20),
new ParseIntPipe({ optional: true }),
)
limit?: number,
) {
return this.adminService.listClients(
page ? parseInt(page, 10) : 1,
limit ? parseInt(limit, 10) : 20,
);
return this.adminService.listClients(page ?? 1, limit ?? 20);
}
@Get('clients/:id')
async getClient(@Param('id') id: string) {
async getClient(@Param('id', ParseUUIDPipe) id: string) {
return this.adminService.getClient(id);
}
@@ -82,12 +108,15 @@ export class AdminController {
}
@Put('clients/:id')
async updateClient(@Param('id') id: string, @Body() dto: UpdateClientDto) {
async updateClient(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdateClientDto,
) {
return this.adminService.updateClient(id, dto);
}
@Delete('clients/:id')
async deleteClient(@Param('id') id: string) {
async deleteClient(@Param('id', ParseUUIDPipe) id: string) {
await this.adminService.deleteClient(id);
return { deleted: true };
}

View File

@@ -1,7 +1,12 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import {
Injectable,
NotFoundException,
ForbiddenException,
} from '@nestjs/common';
import * as argon2 from 'argon2';
import { randomBytes } from 'node:crypto';
import { PinoLogger } from 'nestjs-pino';
import { Role } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { CreateUserDto, UpdateUserDto } from './dto/create-user.dto';
import { CreateClientDto, UpdateClientDto } from './dto/create-client.dto';
@@ -61,21 +66,35 @@ export class AdminService {
return user;
}
async createUser(dto: CreateUserDto) {
async createUser(dto: CreateUserDto, actorId?: string) {
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 },
const role = dto.role === Role.ADMIN ? Role.ADMIN : Role.USER;
return this.prisma.$transaction(async (tx) => {
const user = await tx.user.create({
data: {
email: dto.email,
passwordHash,
displayName: dto.displayName,
phone: dto.phone,
role,
},
select: { id: true, email: true, displayName: true, role: true },
});
await tx.auditLog.create({
data: {
userId: user.id,
action: 'ADMIN_CREATED_USER',
details: { by: actorId, role },
},
});
return user;
});
}
async updateUser(id: string, dto: UpdateUserDto) {
async updateUser(id: string, dto: UpdateUserDto, actorId?: string) {
const user = await this.prisma.user.findUnique({ where: { id } });
if (!user) throw new NotFoundException('User not found');
@@ -83,27 +102,57 @@ export class AdminService {
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);
if (dto.role) {
if (dto.role === Role.ADMIN && user.role !== Role.ADMIN) {
throw new ForbiddenException('Cannot escalate user to ADMIN');
}
if (dto.role === Role.USER && user.role === Role.ADMIN) {
throw new ForbiddenException('Cannot demote ADMIN to USER');
}
data.role = dto.role;
}
return this.prisma.user.update({
where: { id },
data,
select: {
id: true,
email: true,
displayName: true,
role: true,
isActive: true,
},
return this.prisma.$transaction(async (tx) => {
const updated = await tx.user.update({
where: { id },
data,
select: {
id: true,
email: true,
displayName: true,
role: true,
isActive: true,
},
});
await tx.auditLog.create({
data: {
userId: id,
action: 'ADMIN_UPDATED_USER',
details: { by: actorId, changes: Object.keys(data) },
},
});
return updated;
});
}
async deleteUser(id: string) {
async deleteUser(id: string, actorId?: 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 } });
await this.prisma.$transaction(async (tx) => {
await tx.user.delete({ where: { id } });
await tx.auditLog.create({
data: {
userId: id,
action: 'ADMIN_DELETED_USER',
details: { by: actorId, email: user.email },
},
});
});
}
async listClients(page = 1, limit = 20) {
@@ -159,7 +208,6 @@ export class AdminService {
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)

View File

@@ -1,4 +1,18 @@
import { IsString, IsOptional, IsArray, IsBoolean } from 'class-validator';
import {
IsString,
IsOptional,
IsArray,
IsBoolean,
IsUrl,
IsEnum,
ArrayMinSize,
} from 'class-validator';
export enum GrantType {
AUTHORIZATION_CODE = 'authorization_code',
REFRESH_TOKEN = 'refresh_token',
CLIENT_CREDENTIALS = 'client_credentials',
}
export class CreateClientDto {
@IsString()
@@ -16,9 +30,13 @@ export class CreateClientDto {
clientSecret?: string;
@IsArray()
@ArrayMinSize(1)
@IsUrl({ require_tld: false }, { each: true })
redirectUris!: string[];
@IsArray()
@ArrayMinSize(1)
@IsEnum(GrantType, { each: true })
grants!: string[];
@IsOptional()
@@ -45,10 +63,12 @@ export class UpdateClientDto {
@IsOptional()
@IsArray()
@IsUrl({ require_tld: false }, { each: true })
redirectUris?: string[];
@IsOptional()
@IsArray()
@IsEnum(GrantType, { each: true })
grants?: string[];
@IsOptional()

View File

@@ -4,6 +4,7 @@ import {
MinLength,
IsOptional,
IsEnum,
IsBoolean,
} from 'class-validator';
import { Role } from '@prisma/client';
@@ -51,5 +52,6 @@ export class UpdateUserDto {
role?: Role;
@IsOptional()
@IsBoolean()
isActive?: boolean;
}

View File

@@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { ThrottlerModule } from '@nestjs/throttler';
import { APP_FILTER, APP_GUARD } from '@nestjs/core';
import { ThrottlerGuard } from '@nestjs/throttler';
import { LoggerModule } from 'nestjs-pino';
import { PrismaModule } from './prisma/prisma.module';
import { AuthModule } from './auth/auth.module';
@@ -75,6 +76,7 @@ import appConfig from './config/configuration';
],
providers: [
{ provide: APP_FILTER, useClass: AllExceptionsFilter },
{ provide: APP_GUARD, useClass: ThrottlerGuard },
{ provide: APP_GUARD, useClass: JwtAuthGuard },
{ provide: APP_GUARD, useClass: RolesGuard },
],

View File

@@ -6,14 +6,19 @@ import {
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';
import { RegisterDto } from './dto/register.dto';
import { LoginDto } from './dto/login.dto';
import { LoginEmailCodeDto } from './dto/login-email-code.dto';
import { LoginPhoneCodeDto } from './dto/login-phone-code.dto';
import { SendEmailCodeDto, SendPhoneCodeDto } from './dto/send-code.dto';
import { QrPollDto } from './dto/qr-poll.dto';
import { UpdateProfileDto } from './dto/update-profile.dto';
@Controller('auth')
export class AuthController {
@@ -21,34 +26,25 @@ export class AuthController {
@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']);
async register(@Body() dto: RegisterDto, @Req() req: Request) {
return this.authService.register(dto, 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']);
async login(@Body() dto: LoginDto, @Req() req: Request) {
return this.authService.login(dto, 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,
) {
async loginByEmailCode(@Body() dto: LoginEmailCodeDto, @Req() req: Request) {
return this.authService.loginByEmailCode(
body.email,
body.code,
dto.email,
dto.code,
req.ip,
req.headers['user-agent'],
);
@@ -57,13 +53,10 @@ export class AuthController {
@Public()
@Post('login/phone-code')
@HttpCode(HttpStatus.OK)
async loginByPhoneCode(
@Body() body: { phone: string; code: string },
@Req() req: Request,
) {
async loginByPhoneCode(@Body() dto: LoginPhoneCodeDto, @Req() req: Request) {
return this.authService.loginByPhoneCode(
body.phone,
body.code,
dto.phone,
dto.code,
req.ip,
req.headers['user-agent'],
);
@@ -72,16 +65,16 @@ export class AuthController {
@Public()
@Post('send-email-code')
@HttpCode(HttpStatus.OK)
async sendEmailCode(@Body() body: { email: string }) {
await this.authService.sendEmailCode(body.email);
async sendEmailCode(@Body() dto: SendEmailCodeDto) {
await this.authService.sendEmailCode(dto.email);
return { sent: true };
}
@Public()
@Post('send-phone-code')
@HttpCode(HttpStatus.OK)
async sendPhoneCode(@Body() body: { phone: string }) {
await this.authService.sendPhoneCode(body.phone);
async sendPhoneCode(@Body() dto: SendPhoneCodeDto) {
await this.authService.sendPhoneCode(dto.phone);
return { sent: true };
}
@@ -95,8 +88,8 @@ export class AuthController {
@Public()
@Post('qr/poll')
@HttpCode(HttpStatus.OK)
async pollQr(@Body() body: { sessionId: string }) {
return this.authService.pollQrSession(body.sessionId);
async pollQr(@Body() dto: QrPollDto) {
return this.authService.pollQrSession(dto.sessionId);
}
@Public()
@@ -120,21 +113,16 @@ export class AuthController {
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,
) {
async updateProfile(@Body() dto: UpdateProfileDto, @Req() req: Request) {
const user = (req as RequestWithUser).user;
return this.authService.updateProfile(user.sub, body);
return this.authService.updateProfile(user.sub, dto);
}
}

View File

@@ -40,21 +40,25 @@ export class AuthService {
const passwordHash = await argon2.hash(dto.password);
const user = await this.prisma.user.create({
data: {
email: dto.email,
passwordHash,
displayName: dto.displayName,
},
});
const user = await this.prisma.$transaction(async (tx) => {
const u = await tx.user.create({
data: {
email: dto.email,
passwordHash,
displayName: dto.displayName,
},
});
await this.prisma.auditLog.create({
data: {
userId: user.id,
action: 'USER_REGISTERED',
ip,
userAgent,
},
await tx.auditLog.create({
data: {
userId: u.id,
action: 'USER_REGISTERED',
ip,
userAgent,
},
});
return u;
});
this.eventsService.emit('auth.user.registered', {
@@ -199,17 +203,50 @@ export class AuthService {
throw new UnauthorizedException('Session expired');
}
await this.prisma.refreshToken.update({
where: { id: stored.id },
data: { revoked: true },
const result = await this.prisma.$transaction(async (tx) => {
await tx.refreshToken.update({
where: { id: stored.id },
data: { revoked: true },
});
const now = new Date();
const refreshExpiresIn = this.configService.get<string>(
'app.jwt.refreshExpiresIn',
)!;
const refreshMs = this.parseDuration(refreshExpiresIn);
const session = await tx.session.create({
data: {
userId: stored.session.userId,
clientId: stored.session.clientId,
ip: ip ?? stored.session.ip,
userAgent: userAgent ?? stored.session.userAgent,
expiresAt: new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000),
lastUsedAt: now,
},
});
const newRefreshToken = this.generateRefreshToken();
const newRefreshHash = this.hashToken(newRefreshToken);
await tx.refreshToken.create({
data: {
tokenHash: newRefreshHash,
sessionId: session.id,
expiresAt: new Date(now.getTime() + refreshMs),
},
});
return { sessionId: session.id, refreshToken: newRefreshToken };
});
return this.createSession(
stored.session.userId,
stored.session.clientId ?? undefined,
ip ?? stored.session.ip ?? undefined,
userAgent ?? stored.session.userAgent ?? undefined,
);
const accessToken = await this.generateAccessToken(stored.session.userId);
return {
accessToken,
refreshToken: result.refreshToken,
sessionId: result.sessionId,
};
}
async logout(refreshToken: string) {
@@ -220,17 +257,19 @@ export class AuthService {
});
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 },
await this.prisma.$transaction(async (tx) => {
await tx.refreshToken.update({
where: { id: stored.id },
data: { revoked: true },
});
}
if (stored.session) {
await tx.session.update({
where: { id: stored.session.id },
data: { isActive: false },
});
}
});
}
}
@@ -275,38 +314,40 @@ export class AuthService {
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),
},
const now = new Date();
const { id: sessionId } = await this.prisma.$transaction(async (tx) => {
const session = await tx.session.create({
data: {
userId,
clientId,
ip,
userAgent,
expiresAt: new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000),
lastUsedAt: now,
},
});
await tx.refreshToken.create({
data: {
tokenHash: refreshTokenHash,
sessionId: session.id,
expiresAt: new Date(now.getTime() + refreshMs),
},
});
return { id: session.id };
});
return { accessToken, refreshToken, sessionId: session.id };
return { accessToken, refreshToken, sessionId };
}
private async generateAccessToken(userId: string): Promise<string> {
@@ -317,8 +358,7 @@ export class AuthService {
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'),
expiresIn: this.configService.get('app.jwt.accessExpiresIn'),
};
return this.jwtService.signAsync(
{

View File

@@ -0,0 +1,9 @@
import { IsEmail, IsString } from 'class-validator';
export class LoginEmailCodeDto {
@IsEmail()
email!: string;
@IsString()
code!: string;
}

View File

@@ -0,0 +1,9 @@
import { IsString } from 'class-validator';
export class LoginPhoneCodeDto {
@IsString()
phone!: string;
@IsString()
code!: string;
}

View File

@@ -1,10 +1,18 @@
import { IsEmail, IsString, IsOptional } from 'class-validator';
import {
IsEmail,
IsString,
MaxLength,
MinLength,
IsOptional,
} from 'class-validator';
export class LoginDto {
@IsEmail()
email!: string;
@IsString()
@MinLength(1)
@MaxLength(128)
password!: string;
@IsOptional()

View File

@@ -0,0 +1,6 @@
import { IsString } from 'class-validator';
export class QrPollDto {
@IsString()
sessionId!: string;
}

View File

@@ -0,0 +1,11 @@
import { IsEmail, IsString } from 'class-validator';
export class SendEmailCodeDto {
@IsEmail()
email!: string;
}
export class SendPhoneCodeDto {
@IsString()
phone!: string;
}

View File

@@ -0,0 +1,15 @@
import { IsOptional, IsString, IsUrl } from 'class-validator';
export class UpdateProfileDto {
@IsOptional()
@IsString()
displayName?: string;
@IsOptional()
@IsUrl()
avatarUrl?: string;
@IsOptional()
@IsString()
phone?: string;
}

View File

@@ -16,7 +16,20 @@ async function bootstrap(): Promise<void> {
const logger = app.get(Logger);
app.useLogger(logger);
app.use(helmet.default({ contentSecurityPolicy: false }));
app.use(
helmet.default({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", 'https://cdn.jsdelivr.net'],
styleSrc: ["'self'", 'https://cdn.jsdelivr.net', "'unsafe-inline'"],
imgSrc: ["'self'", 'data:'],
fontSrc: ["'self'", 'https://cdn.jsdelivr.net'],
connectSrc: ["'self'"],
},
},
}),
);
app.use(cookieParser());
app.enableCors({

View File

@@ -6,13 +6,15 @@ import {
Query,
HttpCode,
HttpStatus,
Headers,
Req,
Res,
UnauthorizedException,
} from '@nestjs/common';
import type { Request, Response } from 'express';
import { Public } from '../common/decorators/public.decorator';
import { RequestWithUser } from '../common/types';
import { OidcService } from './oidc.service';
import { TokenDto } from './dto/token.dto';
@Controller()
export class OidcController {
@@ -28,12 +30,14 @@ export class OidcController {
@Query('state') state: string,
@Query('code_challenge') codeChallenge: string,
@Query('code_challenge_method') codeChallengeMethod: string,
@Req() req: Request,
@Res() res: Response,
) {
if (responseType !== 'code') {
return res.status(400).json({ error: 'unsupported_response_type' });
}
const user = (req as RequestWithUser).user;
const result = await this.oidcService.authorize(
clientId,
redirectUri,
@@ -41,6 +45,7 @@ export class OidcController {
state,
codeChallenge,
codeChallengeMethod,
user?.sub,
);
return res.redirect(result.redirectUrl);
@@ -49,15 +54,15 @@ export class OidcController {
@Public()
@Post('token')
@HttpCode(HttpStatus.OK)
async token(@Body() body: Record<string, string>) {
async token(@Body() dto: TokenDto) {
return this.oidcService.token(
body.grant_type,
body.code,
body.refresh_token,
body.client_id,
body.client_secret,
body.redirect_uri,
body.code_verifier,
dto.grant_type,
dto.code,
dto.refresh_token,
dto.client_id,
dto.client_secret,
dto.redirect_uri,
dto.code_verifier,
);
}
@@ -66,9 +71,7 @@ export class OidcController {
async userinfo(@Req() req: Request) {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
throw new (await import('@nestjs/common')).UnauthorizedException(
'Missing token',
);
throw new UnauthorizedException('Missing token');
}
const token = authHeader.slice(7);
return this.oidcService.userinfo(token);

View File

@@ -30,6 +30,7 @@ export class OidcService {
state?: string,
codeChallenge?: string,
codeChallengeMethod?: string,
userId?: string,
) {
const client = await this.prisma.client.findUnique({
where: { clientId },
@@ -43,24 +44,35 @@ export class OidcService {
throw new BadRequestException('Invalid redirect_uri');
}
if (!client.isConfidential && !codeChallenge) {
throw new BadRequestException('Public clients must use PKCE');
}
const authorizationCode = randomBytes(32).toString('hex');
const codeHash = createHash('sha256')
.update(authorizationCode)
.digest('hex');
await this.prisma.authorizationCode.create({
data: {
codeHash,
clientId,
userId: userId ?? null,
redirectUri,
codeChallenge: codeChallenge ?? null,
codeChallengeMethod: codeChallengeMethod ?? null,
scope,
expiresAt: new Date(Date.now() + 10 * 60 * 1000),
},
});
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,
);
if (state) {
redirectUrl.searchParams.set('state', state);
}
return { redirectUrl: redirectUrl.toString(), codeHash };
return { redirectUrl: redirectUrl.toString() };
}
async token(
@@ -147,23 +159,68 @@ export class OidcService {
}
}
if (redirectUri && !client.redirectUris.includes(redirectUri)) {
throw new BadRequestException('Invalid redirect_uri');
const codeHash = createHash('sha256').update(code).digest('hex');
const storedCode = await this.prisma.authorizationCode.findUnique({
where: { codeHash },
});
if (!storedCode || storedCode.usedAt || storedCode.expiresAt < new Date()) {
if (storedCode && !storedCode.usedAt) {
await this.prisma.authorizationCode.update({
where: { id: storedCode.id },
data: { usedAt: new Date() },
});
}
throw new BadRequestException('Invalid or expired authorization code');
}
if (codeVerifier) {
void createHash('sha256').update(codeVerifier).digest('base64url');
if (storedCode.clientId !== clientId) {
throw new BadRequestException('Code was issued for a different client');
}
if (redirectUri && storedCode.redirectUri !== redirectUri) {
throw new BadRequestException('Redirect URI mismatch');
}
if (storedCode.codeChallenge && !codeVerifier) {
throw new BadRequestException('PKCE code_verifier required');
}
if (codeVerifier && storedCode.codeChallenge) {
const method = storedCode.codeChallengeMethod ?? 'S256';
let verifierChallenge: string;
if (method === 'S256') {
verifierChallenge = createHash('sha256')
.update(codeVerifier)
.digest('base64url');
} else {
verifierChallenge = codeVerifier;
}
if (verifierChallenge !== storedCode.codeChallenge) {
throw new UnauthorizedException('PKCE verification failed');
}
}
await this.prisma.authorizationCode.update({
where: { id: storedCode.id },
data: { usedAt: new Date() },
});
const userId = storedCode.userId;
if (!userId) {
throw new UnauthorizedException('No user associated with this code');
}
const refreshToken = randomBytes(64).toString('hex');
const options = {
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'),
} satisfies JwtSignOptions;
expiresIn: this.configService.get('app.jwt.accessExpiresIn'),
};
const accessToken = await this.jwtService.signAsync(
{ sub: clientId, client_id: clientId },
{ sub: userId, client_id: clientId },
options,
);
@@ -187,11 +244,10 @@ export class OidcService {
throw new UnauthorizedException('Invalid client credentials');
}
const options = {
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'),
} satisfies JwtSignOptions;
expiresIn: this.configService.get('app.jwt.accessExpiresIn'),
};
const accessToken = await this.jwtService.signAsync(
{ sub: clientId, client_id: clientId },
options,

View File

@@ -1,7 +1,7 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import { v4 as uuid } from 'uuid';
import { randomUUID } from 'node:crypto';
import * as qrcode from 'qrcode';
import { PinoLogger } from 'nestjs-pino';
import { PrismaService } from '../prisma/prisma.service';
@@ -25,8 +25,13 @@ export class QrAuthService {
qrDataUrl: string;
qrToken: string;
}> {
const qrSessionId = uuid();
const qrToken = uuid();
const qrSessionId = randomUUID();
const qrToken = randomUUID();
await this.prisma.qrSession.updateMany({
where: { status: 'PENDING', expiresAt: { gt: new Date() } },
data: { status: 'EXPIRED' },
});
const qrSession = await this.prisma.qrSession.create({
data: {
@@ -113,30 +118,50 @@ export class QrAuthService {
}
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 result = await this.prisma.$transaction(async (tx) => {
const s = await tx.qrSession.findUnique({
where: { id: session.id },
});
if (!s || s.status !== 'CONFIRMED') {
return { status: 'PENDING' as const };
}
const accessToken = await this.jwtService.signAsync(
{
sub: user.id,
const user = await tx.user.findUnique({
where: { id: s.userId! },
select: { id: true, email: true, role: true },
});
if (!user) return { status: 'ERROR' as const };
await tx.qrSession.update({
where: { id: s.id },
data: { status: 'EXPIRED' },
});
return {
status: 'CONFIRMED' as const,
userId: 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' },
};
});
if (result.status !== 'CONFIRMED') {
return { status: result.status };
}
const options: import('@nestjs/jwt').JwtSignOptions = {
secret: this.configService.get<string>('app.jwt.accessSecret'),
expiresIn: this.configService.get('app.jwt.accessExpiresIn'),
};
const accessToken = await this.jwtService.signAsync(
{
sub: result.userId,
email: result.email,
role: result.role,
} satisfies JwtPayload,
options,
);
return { accessToken, status: 'CONFIRMED' };
}

View File

@@ -19,6 +19,11 @@ export class VerificationService {
}
async sendEmailCode(email: string): Promise<void> {
await this.prisma.verificationCode.updateMany({
where: { target: email, usedAt: null, expiresAt: { gt: new Date() } },
data: { usedAt: new Date() },
});
const code = this.generateCode();
await this.prisma.verificationCode.create({
data: {
@@ -30,10 +35,18 @@ export class VerificationService {
},
});
await this.mailService.sendCode(email, code, 'AUTH');
this.logger.info({ email }, 'Email verification code sent');
this.logger.info(
{ target: email, channel: 'email' },
'Verification code sent',
);
}
async sendPhoneCode(phone: string): Promise<void> {
await this.prisma.verificationCode.updateMany({
where: { target: phone, usedAt: null, expiresAt: { gt: new Date() } },
data: { usedAt: new Date() },
});
const code = this.generateCode();
await this.prisma.verificationCode.create({
data: {
@@ -44,7 +57,10 @@ export class VerificationService {
expiresAt: new Date(Date.now() + 5 * 60 * 1000),
},
});
this.logger.info({ phone }, 'SMS verification code would be sent (mock)');
this.logger.info(
{ target: phone, channel: 'sms' },
'Verification code would be sent (mock)',
);
}
async verifyCode(target: string, code: string): Promise<boolean> {
@@ -59,6 +75,10 @@ export class VerificationService {
});
if (!record) {
await this.prisma.verificationCode.updateMany({
where: { target, usedAt: null, expiresAt: { gt: new Date() } },
data: { usedAt: new Date() },
});
throw new BadRequestException('Invalid or expired code');
}