third commit
This commit is contained in:
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
],
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
9
src/auth/dto/login-email-code.dto.ts
Normal file
9
src/auth/dto/login-email-code.dto.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { IsEmail, IsString } from 'class-validator';
|
||||
|
||||
export class LoginEmailCodeDto {
|
||||
@IsEmail()
|
||||
email!: string;
|
||||
|
||||
@IsString()
|
||||
code!: string;
|
||||
}
|
||||
9
src/auth/dto/login-phone-code.dto.ts
Normal file
9
src/auth/dto/login-phone-code.dto.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { IsString } from 'class-validator';
|
||||
|
||||
export class LoginPhoneCodeDto {
|
||||
@IsString()
|
||||
phone!: string;
|
||||
|
||||
@IsString()
|
||||
code!: string;
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
6
src/auth/dto/qr-poll.dto.ts
Normal file
6
src/auth/dto/qr-poll.dto.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { IsString } from 'class-validator';
|
||||
|
||||
export class QrPollDto {
|
||||
@IsString()
|
||||
sessionId!: string;
|
||||
}
|
||||
11
src/auth/dto/send-code.dto.ts
Normal file
11
src/auth/dto/send-code.dto.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { IsEmail, IsString } from 'class-validator';
|
||||
|
||||
export class SendEmailCodeDto {
|
||||
@IsEmail()
|
||||
email!: string;
|
||||
}
|
||||
|
||||
export class SendPhoneCodeDto {
|
||||
@IsString()
|
||||
phone!: string;
|
||||
}
|
||||
15
src/auth/dto/update-profile.dto.ts
Normal file
15
src/auth/dto/update-profile.dto.ts
Normal 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;
|
||||
}
|
||||
15
src/main.ts
15
src/main.ts
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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' };
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user