fix: harden tenant auth and quality gates
This commit is contained in:
@@ -9,4 +9,12 @@ module.exports = {
|
||||
testMatch: ['**/*.spec.ts'],
|
||||
moduleFileExtensions: ['ts', 'js', 'json'],
|
||||
clearMocks: true,
|
||||
coverageThreshold: {
|
||||
global: {
|
||||
statements: 59,
|
||||
branches: 50,
|
||||
functions: 60,
|
||||
lines: 62,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.build.json",
|
||||
"test": "jest --runInBand",
|
||||
"test:coverage": "jest --runInBand --coverage",
|
||||
"test:watch": "jest --watch",
|
||||
"start": "node dist/main.js",
|
||||
"start:dev": "ts-node src/main.ts",
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
import { defineConfig } from 'prisma/config';
|
||||
|
||||
const databaseUrl = process.env.DATABASE_URL?.trim();
|
||||
const allowDevelopmentDefault = process.env.NODE_ENV === 'development' || process.env.NODE_ENV === 'test';
|
||||
if (!databaseUrl && !allowDevelopmentDefault) {
|
||||
throw new Error('DATABASE_URL is required outside development and test environments');
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
schema: 'prisma/schema.prisma',
|
||||
migrations: {
|
||||
path: 'prisma/migrations',
|
||||
},
|
||||
datasource: {
|
||||
url:
|
||||
process.env.DATABASE_URL ??
|
||||
'postgresql://cmpp:cmpp_password@localhost:5432/cmpp_platform?schema=public',
|
||||
url: databaseUrl ?? 'postgresql://cmpp:cmpp_password@localhost:5432/cmpp_platform?schema=public',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { Body, Controller, Get, Post, Req, Res, UnauthorizedException } from '@nestjs/common';
|
||||
import { Body, Controller, Get, Post, Req, Res, UnauthorizedException, UsePipes } from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { CurrentSessionUserId } from './current-session-user.decorator';
|
||||
import { AuthService, LoginDto } from './auth.service';
|
||||
import { AuthService } from './auth.service';
|
||||
import { ChangeOwnPasswordDto, LoginDto, PasswordVerificationDto } from './auth.dto';
|
||||
import { strictValidationPipe } from '../common/strict-validation.pipe';
|
||||
import { DEVELOPMENT_SESSION_COOKIE_NAME, SESSION_COOKIE_NAME, SessionPortal, SessionService } from './session.service';
|
||||
import type { SessionRequest } from './session-validation.middleware';
|
||||
import { UsersService } from '../users/users.service';
|
||||
@@ -26,6 +28,7 @@ export class AuthController {
|
||||
}
|
||||
|
||||
@Post('admin/auth/login')
|
||||
@UsePipes(strictValidationPipe)
|
||||
async adminLogin(@Body() body: LoginDto, @Req() request: SessionRequest, @Res({ passthrough: true }) response: CookieResponse) {
|
||||
let result: Awaited<ReturnType<AuthService['login']>>;
|
||||
try {
|
||||
@@ -43,6 +46,7 @@ export class AuthController {
|
||||
}
|
||||
|
||||
@Post('client/auth/login')
|
||||
@UsePipes(strictValidationPipe)
|
||||
async clientLogin(@Body() body: LoginDto, @Req() request: SessionRequest, @Res({ passthrough: true }) response: CookieResponse) {
|
||||
let result: Awaited<ReturnType<AuthService['login']>>;
|
||||
try {
|
||||
@@ -95,7 +99,9 @@ export class AuthController {
|
||||
}
|
||||
|
||||
@Post(['admin/auth/session/unlock', 'client/auth/session/unlock'])
|
||||
async unlock(@Req() request: SessionRequest, @Body('password') password: string, @Res({ passthrough: true }) response: CookieResponse) {
|
||||
@UsePipes(strictValidationPipe)
|
||||
async unlock(@Req() request: SessionRequest, @Body() body: PasswordVerificationDto, @Res({ passthrough: true }) response: CookieResponse) {
|
||||
const { password } = body;
|
||||
this.assertSession(request);
|
||||
const result = await this.auth.unlock(request.sessionToken!, request.sessionUserId!, password);
|
||||
if (result.status !== 'active' || !('token' in result)) throw new UnauthorizedException({ code: 'SESSION_LOCK_TIMEOUT', message: '锁定时间过长,请重新登录' });
|
||||
@@ -105,7 +111,9 @@ export class AuthController {
|
||||
}
|
||||
|
||||
@Post(['admin/auth/reauthenticate', 'client/auth/reauthenticate'])
|
||||
async reauthenticate(@Req() request: SessionRequest, @Body('password') password: string) {
|
||||
@UsePipes(strictValidationPipe)
|
||||
async reauthenticate(@Req() request: SessionRequest, @Body() body: PasswordVerificationDto) {
|
||||
const { password } = body;
|
||||
this.assertSession(request);
|
||||
const result = await this.auth.reauthenticate(request.sessionToken!, request.sessionUserId!, password);
|
||||
if (result.status !== 'active') throw new UnauthorizedException({ code: 'SESSION_LOCKED', message: '会话已锁定' });
|
||||
@@ -122,9 +130,10 @@ export class AuthController {
|
||||
}
|
||||
|
||||
@Post(['admin/auth/password', 'client/auth/password'])
|
||||
changeOwnPassword(@CurrentSessionUserId() userId: string | undefined, @Body() body: { currentPassword?: string; password?: string }) {
|
||||
@UsePipes(strictValidationPipe)
|
||||
changeOwnPassword(@CurrentSessionUserId() userId: string | undefined, @Body() body: ChangeOwnPasswordDto) {
|
||||
if (!userId) throw new UnauthorizedException('登录会话无效,请重新登录');
|
||||
return this.users.changeOwnPassword(userId, body.currentPassword ?? '', body.password ?? '');
|
||||
return this.users.changeOwnPassword(userId, body.currentPassword, body.password);
|
||||
}
|
||||
|
||||
private async finishLogin(result: Awaited<ReturnType<AuthService['login']>>, request: SessionRequest, response: CookieResponse) {
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { IsString, MaxLength, MinLength } from 'class-validator';
|
||||
|
||||
export class LoginDto {
|
||||
@IsString() @MinLength(1) @MaxLength(200) login!: string;
|
||||
@IsString() @MinLength(1) @MaxLength(256) password!: string;
|
||||
@IsString() @MinLength(1) @MaxLength(64) captchaId!: string;
|
||||
@IsString() @MinLength(1) @MaxLength(32) captchaText!: string;
|
||||
}
|
||||
|
||||
export class PasswordVerificationDto {
|
||||
@IsString() @MinLength(1) @MaxLength(256) password!: string;
|
||||
}
|
||||
|
||||
export class ChangeOwnPasswordDto {
|
||||
@IsString() @MinLength(1) @MaxLength(256) currentPassword!: string;
|
||||
@IsString() @MinLength(8) @MaxLength(256) password!: string;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { UnauthorizedException } from '@nestjs/common';
|
||||
import { AuthService } from './auth.service';
|
||||
import { hashPassword } from '../users/users.service';
|
||||
import { legacyHashPassword } from './password-hasher';
|
||||
|
||||
function createUsersMock(roleCode: string, overrides: Record<string, unknown> = {}) {
|
||||
const user = {
|
||||
@@ -10,7 +10,7 @@ function createUsersMock(roleCode: string, overrides: Record<string, unknown> =
|
||||
email: 'user@example.com',
|
||||
phone: '13800000000',
|
||||
displayName: '用户',
|
||||
passwordHash: hashPassword('secret1'),
|
||||
passwordHash: legacyHashPassword('secret1'),
|
||||
status: 'active',
|
||||
deletedAt: null,
|
||||
lockedUntil: null,
|
||||
@@ -20,24 +20,32 @@ function createUsersMock(roleCode: string, overrides: Record<string, unknown> =
|
||||
};
|
||||
return {
|
||||
findByLogin: jest.fn().mockResolvedValue(user),
|
||||
verifyLoginPassword: jest.fn(async (_id: string, password: string) => password === 'secret1'),
|
||||
recordLoginSuccess: jest.fn(),
|
||||
recordLoginFailure: jest.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
function createSessionsMock() {
|
||||
const captchas = new Map<string, string>();
|
||||
const failures = new Map<string, number>();
|
||||
const record = {
|
||||
userId: 'user-1', portal: 'admin', sessionVersion: 0, createdAt: 1, lastActivityAt: 1,
|
||||
lastAuthenticatedAt: 1, absoluteExpiresAt: Date.now() + 1000,
|
||||
};
|
||||
return {
|
||||
storeCaptcha: jest.fn(async (id: string, answer: string) => { captchas.set(id, answer); }),
|
||||
consumeCaptcha: jest.fn(async (id: string) => { const answer = captchas.get(id) ?? null; captchas.delete(id); return answer; }),
|
||||
isAnonymousLoginLocked: jest.fn(async (login: string) => (failures.get(login) ?? 0) >= 5),
|
||||
recordAnonymousLoginFailure: jest.fn(async (login: string) => { const count = (failures.get(login) ?? 0) + 1; failures.set(login, count); return count; }),
|
||||
clearAnonymousLoginFailures: jest.fn(async (login: string) => { failures.delete(login); }),
|
||||
create: jest.fn().mockResolvedValue({ token: 'opaque-session-token', record }),
|
||||
publicSession: jest.fn().mockReturnValue({ idleTimeoutSeconds: 3600, absoluteExpiresAt: new Date(record.absoluteExpiresAt).toISOString() }),
|
||||
};
|
||||
}
|
||||
|
||||
async function loginWithCaptcha(service: AuthService, portal: 'admin' | 'client', password = 'secret1') {
|
||||
const captcha = service.createCaptcha();
|
||||
const captcha = await service.createCaptcha();
|
||||
const answer = captcha.challenge.split('=')[0].split('+').map((part) => Number(part.trim())).reduce((sum, value) => sum + value, 0);
|
||||
return service.login({
|
||||
login: 'user@example.com',
|
||||
|
||||
@@ -1,37 +1,20 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { BadRequestException, Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { hashPassword, UsersService } from '../users/users.service';
|
||||
import { UsersService } from '../users/users.service';
|
||||
import type { LoginDto } from './auth.dto';
|
||||
import { SessionService } from './session.service';
|
||||
|
||||
export interface LoginDto {
|
||||
login: string;
|
||||
password: string;
|
||||
captchaId: string;
|
||||
captchaText: string;
|
||||
}
|
||||
|
||||
type LoginPortal = 'admin' | 'client';
|
||||
|
||||
type CaptchaRecord = {
|
||||
answer: string;
|
||||
expiresAt: number;
|
||||
};
|
||||
|
||||
const captchaStore = new Map<string, CaptchaRecord>();
|
||||
const anonymousFailures = new Map<string, { count: number; lockedUntil?: number }>();
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(private readonly users: UsersService, private readonly sessions: SessionService) {}
|
||||
|
||||
createCaptcha() {
|
||||
async createCaptcha() {
|
||||
const left = Math.floor(10 + Math.random() * 40);
|
||||
const right = Math.floor(1 + Math.random() * 9);
|
||||
const captchaId = randomUUID();
|
||||
captchaStore.set(captchaId, {
|
||||
answer: String(left + right),
|
||||
expiresAt: Date.now() + 5 * 60 * 1000,
|
||||
});
|
||||
await this.sessions.storeCaptcha(captchaId, String(left + right), 5 * 60);
|
||||
return {
|
||||
captchaId,
|
||||
challenge: `${left} + ${right} = ?`,
|
||||
@@ -44,12 +27,12 @@ export class AuthService {
|
||||
if (!login || !data.password) {
|
||||
throw new BadRequestException('login and password are required');
|
||||
}
|
||||
this.verifyCaptcha(data.captchaId, data.captchaText);
|
||||
this.assertAnonymousNotLocked(login);
|
||||
await this.verifyCaptcha(data.captchaId, data.captchaText);
|
||||
await this.assertAnonymousNotLocked(login);
|
||||
|
||||
const user = await this.users.findByLogin(login);
|
||||
if (!user) {
|
||||
this.recordAnonymousFailure(login);
|
||||
await this.sessions.recordAnonymousLoginFailure(login);
|
||||
throw new UnauthorizedException('Invalid login or password');
|
||||
}
|
||||
if (user.lockedUntil && user.lockedUntil.getTime() > Date.now()) {
|
||||
@@ -59,7 +42,7 @@ export class AuthService {
|
||||
await this.users.recordLoginFailure(user.id);
|
||||
throw new UnauthorizedException('User is disabled or deleted');
|
||||
}
|
||||
if (user.passwordHash !== hashPassword(data.password)) {
|
||||
if (!await this.users.verifyLoginPassword(user.id, data.password, user.passwordHash)) {
|
||||
await this.users.recordLoginFailure(user.id);
|
||||
throw new UnauthorizedException('Invalid login or password');
|
||||
}
|
||||
@@ -75,7 +58,7 @@ export class AuthService {
|
||||
}
|
||||
|
||||
await this.users.recordLoginSuccess(user.id);
|
||||
anonymousFailures.delete(login);
|
||||
await this.sessions.clearAnonymousLoginFailures(login);
|
||||
|
||||
const { token, record } = await this.sessions.create(user.id, portal, user.sessionVersion ?? 0);
|
||||
return {
|
||||
@@ -105,30 +88,19 @@ export class AuthService {
|
||||
return this.sessions.markReauthenticated(token);
|
||||
}
|
||||
|
||||
private verifyCaptcha(captchaId?: string, captchaText?: string) {
|
||||
const record = captchaId ? captchaStore.get(captchaId) : undefined;
|
||||
captchaStore.delete(captchaId ?? '');
|
||||
if (!record || record.expiresAt < Date.now()) {
|
||||
private async verifyCaptcha(captchaId?: string, captchaText?: string) {
|
||||
const answer = captchaId ? await this.sessions.consumeCaptcha(captchaId) : null;
|
||||
if (!answer) {
|
||||
throw new BadRequestException('Captcha expired, refresh and try again');
|
||||
}
|
||||
if (record.answer !== captchaText?.trim()) {
|
||||
if (answer !== captchaText?.trim()) {
|
||||
throw new BadRequestException('Captcha is incorrect');
|
||||
}
|
||||
}
|
||||
|
||||
private assertAnonymousNotLocked(login: string) {
|
||||
const current = anonymousFailures.get(login);
|
||||
if (current?.lockedUntil && current.lockedUntil > Date.now()) {
|
||||
private async assertAnonymousNotLocked(login: string) {
|
||||
if (await this.sessions.isAnonymousLoginLocked(login)) {
|
||||
throw new UnauthorizedException('User is locked for 24 hours after repeated failures');
|
||||
}
|
||||
}
|
||||
|
||||
private recordAnonymousFailure(login: string) {
|
||||
const current = anonymousFailures.get(login) ?? { count: 0 };
|
||||
const count = current.count + 1;
|
||||
anonymousFailures.set(login, {
|
||||
count,
|
||||
lockedUntil: count >= 5 ? Date.now() + 24 * 60 * 60 * 1000 : current.lockedUntil,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { ForbiddenException, createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||
import type { SessionRequest } from './session-validation.middleware';
|
||||
|
||||
/**
|
||||
* Returns the tenant bound to the authenticated client session.
|
||||
* Request headers, query parameters and request bodies must never determine this value.
|
||||
*/
|
||||
export const CurrentTenantId = createParamDecorator((_: unknown, context: ExecutionContext) => {
|
||||
const request = context.switchToHttp().getRequest<SessionRequest>();
|
||||
if (request.authSession?.portal !== 'client' || !request.sessionTenantId) {
|
||||
throw new ForbiddenException({ code: 'CLIENT_TENANT_REQUIRED', message: '缺少可信企业上下文' });
|
||||
}
|
||||
return request.sessionTenantId;
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import { hashPassword, isLegacySha256, legacyHashPassword, passwordNeedsRehash, verifyPassword } from './password-hasher';
|
||||
|
||||
describe('password hasher', () => {
|
||||
it('stores new passwords using versioned salted scrypt hashes', async () => {
|
||||
const first = await hashPassword('correct horse battery staple');
|
||||
const second = await hashPassword('correct horse battery staple');
|
||||
|
||||
expect(first).toMatch(/^\$scrypt\$v=1\$/);
|
||||
expect(second).not.toBe(first);
|
||||
await expect(verifyPassword('correct horse battery staple', first)).resolves.toBe(true);
|
||||
await expect(verifyPassword('wrong password', first)).resolves.toBe(false);
|
||||
expect(passwordNeedsRehash(first)).toBe(false);
|
||||
});
|
||||
|
||||
it('recognizes and verifies legacy SHA-256 hashes for transparent migration', async () => {
|
||||
const legacy = legacyHashPassword('legacy-password');
|
||||
|
||||
expect(isLegacySha256(legacy)).toBe(true);
|
||||
expect(passwordNeedsRehash(legacy)).toBe(true);
|
||||
await expect(verifyPassword('legacy-password', legacy)).resolves.toBe(true);
|
||||
await expect(verifyPassword('wrong-password', legacy)).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('rejects malformed or excessive scrypt parameters', async () => {
|
||||
await expect(verifyPassword('password', '$scrypt$v=1$N=1048576,r=8,p=1$YWJjZGVmZ2hpamtsbW5vcA$YWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXowMTIzNDU')).resolves.toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
import { createHash, randomBytes, scrypt as scryptCallback, timingSafeEqual } from 'node:crypto';
|
||||
const VERSION = 1;
|
||||
const KEY_LENGTH = 32;
|
||||
const DEFAULT_N = 32768;
|
||||
const DEFAULT_R = 8;
|
||||
const DEFAULT_P = 1;
|
||||
const MAX_MEMORY = 64 * 1024 * 1024;
|
||||
const LEGACY_SHA256 = /^[a-f0-9]{64}$/i;
|
||||
|
||||
export async function hashPassword(password: string) {
|
||||
const salt = randomBytes(16);
|
||||
const derived = await deriveScrypt(password, salt, KEY_LENGTH, {
|
||||
N: DEFAULT_N,
|
||||
r: DEFAULT_R,
|
||||
p: DEFAULT_P,
|
||||
maxmem: MAX_MEMORY,
|
||||
});
|
||||
return `$scrypt$v=${VERSION}$N=${DEFAULT_N},r=${DEFAULT_R},p=${DEFAULT_P}$${salt.toString('base64url')}$${derived.toString('base64url')}`;
|
||||
}
|
||||
|
||||
export async function verifyPassword(password: string, encoded: string) {
|
||||
if (isLegacySha256(encoded)) {
|
||||
const candidate = Buffer.from(legacyHashPassword(password), 'hex');
|
||||
const expected = Buffer.from(encoded, 'hex');
|
||||
return candidate.length === expected.length && timingSafeEqual(candidate, expected);
|
||||
}
|
||||
|
||||
const parsed = parseScryptHash(encoded);
|
||||
if (!parsed) return false;
|
||||
const derived = await deriveScrypt(password, parsed.salt, parsed.hash.length, {
|
||||
N: parsed.N,
|
||||
r: parsed.r,
|
||||
p: parsed.p,
|
||||
maxmem: MAX_MEMORY,
|
||||
});
|
||||
return derived.length === parsed.hash.length && timingSafeEqual(derived, parsed.hash);
|
||||
}
|
||||
|
||||
export function passwordNeedsRehash(encoded: string) {
|
||||
if (isLegacySha256(encoded)) return true;
|
||||
const parsed = parseScryptHash(encoded);
|
||||
return !parsed || parsed.version !== VERSION || parsed.N !== DEFAULT_N || parsed.r !== DEFAULT_R || parsed.p !== DEFAULT_P;
|
||||
}
|
||||
|
||||
export function isLegacySha256(encoded: string) {
|
||||
return LEGACY_SHA256.test(encoded);
|
||||
}
|
||||
|
||||
export function legacyHashPassword(password: string) {
|
||||
return createHash('sha256').update(password).digest('hex');
|
||||
}
|
||||
|
||||
function parseScryptHash(encoded: string) {
|
||||
const match = /^\$scrypt\$v=(\d+)\$N=(\d+),r=(\d+),p=(\d+)\$([A-Za-z0-9_-]+)\$([A-Za-z0-9_-]+)$/.exec(encoded);
|
||||
if (!match) return undefined;
|
||||
const [, version, N, r, p, salt, hash] = match;
|
||||
const params = { version: Number(version), N: Number(N), r: Number(r), p: Number(p) };
|
||||
if (!Number.isInteger(params.N) || params.N < 2 || params.N > DEFAULT_N
|
||||
|| !Number.isInteger(params.r) || params.r < 1 || params.r > DEFAULT_R
|
||||
|| !Number.isInteger(params.p) || params.p < 1 || params.p > DEFAULT_P) return undefined;
|
||||
try {
|
||||
const decodedSalt = Buffer.from(salt, 'base64url');
|
||||
const decodedHash = Buffer.from(hash, 'base64url');
|
||||
if (decodedSalt.length < 16 || decodedHash.length !== KEY_LENGTH) return undefined;
|
||||
return { ...params, salt: decodedSalt, hash: decodedHash };
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function deriveScrypt(password: string, salt: Buffer, keyLength: number, options: { N: number; r: number; p: number; maxmem: number }) {
|
||||
return new Promise<Buffer>((resolve, reject) => {
|
||||
scryptCallback(password, salt, keyLength, options, (error, derivedKey) => {
|
||||
if (error) reject(error);
|
||||
else resolve(derivedKey);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { UnauthorizedException } from '@nestjs/common';
|
||||
import { ForbiddenException, UnauthorizedException } from '@nestjs/common';
|
||||
import { SessionValidationMiddleware, type SessionRequest } from './session-validation.middleware';
|
||||
|
||||
const record = {
|
||||
@@ -6,10 +6,10 @@ const record = {
|
||||
lastActivityAt: 1, lastAuthenticatedAt: 1, absoluteExpiresAt: Date.now() + 1000,
|
||||
};
|
||||
|
||||
function request(path = '/api/admin/users', cookie = 'cmpp_admin_session=opaque-token'): SessionRequest {
|
||||
function request(path = '/api/admin/users', cookie = 'cmpp_admin_session=opaque-token', tenantId?: string): SessionRequest {
|
||||
return {
|
||||
originalUrl: path,
|
||||
header: jest.fn((name: string) => name === 'cookie' ? cookie : undefined),
|
||||
header: jest.fn((name: string) => name === 'cookie' ? cookie : name === 'x-tenant-id' ? tenantId : undefined),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ describe('SessionValidationMiddleware', () => {
|
||||
const cookieName = jest.fn((portal: 'admin' | 'client') => `cmpp_${portal}_session`);
|
||||
|
||||
it('accepts an active Redis session and exposes its user and record', async () => {
|
||||
const prisma = { user: { findUnique: jest.fn().mockResolvedValue({ id: 'user-1', status: 'active', deletedAt: null, sessionVersion: 3 }) } };
|
||||
const prisma = { user: { findUnique: jest.fn().mockResolvedValue({ id: 'user-1', tenantId: null, status: 'active', deletedAt: null, sessionVersion: 3 }) } };
|
||||
const sessions = { cookieName, validate: jest.fn().mockResolvedValue({ status: 'active', record }), remove: jest.fn() };
|
||||
const middleware = new SessionValidationMiddleware(prisma as never, sessions as never);
|
||||
const currentRequest = request();
|
||||
@@ -32,7 +32,7 @@ describe('SessionValidationMiddleware', () => {
|
||||
});
|
||||
|
||||
it('rejects a session after the user session version changes', async () => {
|
||||
const prisma = { user: { findUnique: jest.fn().mockResolvedValue({ id: 'user-1', status: 'active', deletedAt: null, sessionVersion: 4 }) } };
|
||||
const prisma = { user: { findUnique: jest.fn().mockResolvedValue({ id: 'user-1', tenantId: null, status: 'active', deletedAt: null, sessionVersion: 4 }) } };
|
||||
const sessions = { cookieName, validate: jest.fn().mockResolvedValue({ status: 'active', record }), remove: jest.fn() };
|
||||
const middleware = new SessionValidationMiddleware(prisma as never, sessions as never);
|
||||
|
||||
@@ -41,7 +41,7 @@ describe('SessionValidationMiddleware', () => {
|
||||
});
|
||||
|
||||
it('only lets a locked session reach unlock and logout endpoints', async () => {
|
||||
const prisma = { user: { findUnique: jest.fn().mockResolvedValue({ id: 'user-1', status: 'active', deletedAt: null, sessionVersion: 3 }) } };
|
||||
const prisma = { user: { findUnique: jest.fn().mockResolvedValue({ id: 'user-1', tenantId: null, status: 'active', deletedAt: null, sessionVersion: 3 }) } };
|
||||
const sessions = { cookieName, validate: jest.fn().mockResolvedValue({ status: 'locked', record }), remove: jest.fn() };
|
||||
const middleware = new SessionValidationMiddleware(prisma as never, sessions as never);
|
||||
|
||||
@@ -53,7 +53,7 @@ describe('SessionValidationMiddleware', () => {
|
||||
|
||||
it('selects only the cookie belonging to the requested portal', async () => {
|
||||
const clientRecord = { ...record, portal: 'client' as const };
|
||||
const prisma = { user: { findUnique: jest.fn().mockResolvedValue({ id: 'user-1', status: 'active', deletedAt: null, sessionVersion: 3 }) } };
|
||||
const prisma = { user: { findUnique: jest.fn().mockResolvedValue({ id: 'user-1', tenantId: 'tenant-a', status: 'active', deletedAt: null, sessionVersion: 3 }) } };
|
||||
const sessions = { cookieName, validate: jest.fn().mockResolvedValue({ status: 'active', record: clientRecord }), remove: jest.fn() };
|
||||
const middleware = new SessionValidationMiddleware(prisma as never, sessions as never);
|
||||
const currentRequest = request('/api/client/users', 'cmpp_admin_session=admin-token; cmpp_client_session=client-token');
|
||||
@@ -62,6 +62,33 @@ describe('SessionValidationMiddleware', () => {
|
||||
|
||||
expect(sessions.validate).toHaveBeenCalledWith('client-token', false);
|
||||
expect(currentRequest.sessionToken).toBe('client-token');
|
||||
expect(currentRequest.sessionTenantId).toBe('tenant-a');
|
||||
});
|
||||
|
||||
it('rejects a client session whose user is not bound to a tenant', async () => {
|
||||
const clientRecord = { ...record, portal: 'client' as const };
|
||||
const prisma = { user: { findUnique: jest.fn().mockResolvedValue({ id: 'user-1', tenantId: null, status: 'active', deletedAt: null, sessionVersion: 3 }) } };
|
||||
const sessions = { cookieName, validate: jest.fn().mockResolvedValue({ status: 'active', record: clientRecord }), remove: jest.fn() };
|
||||
const middleware = new SessionValidationMiddleware(prisma as never, sessions as never);
|
||||
|
||||
await expect(middleware.use(request('/api/client/users', 'cmpp_client_session=client-token'), {}, jest.fn()))
|
||||
.rejects.toBeInstanceOf(ForbiddenException);
|
||||
});
|
||||
|
||||
it('rejects and audits a client tenant header that disagrees with the authenticated user', async () => {
|
||||
const clientRecord = { ...record, portal: 'client' as const };
|
||||
const prisma = {
|
||||
user: { findUnique: jest.fn().mockResolvedValue({ id: 'user-1', tenantId: 'tenant-a', status: 'active', deletedAt: null, sessionVersion: 3 }) },
|
||||
operationLog: { create: jest.fn().mockResolvedValue({ id: 'log-1' }) },
|
||||
};
|
||||
const sessions = { cookieName, validate: jest.fn().mockResolvedValue({ status: 'active', record: clientRecord }), remove: jest.fn() };
|
||||
const middleware = new SessionValidationMiddleware(prisma as never, sessions as never);
|
||||
|
||||
await expect(middleware.use(request('/api/client/users', 'cmpp_client_session=client-token', 'tenant-b'), {}, jest.fn()))
|
||||
.rejects.toMatchObject({ response: expect.objectContaining({ code: 'CLIENT_TENANT_MISMATCH' }) });
|
||||
expect(prisma.operationLog.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({ tenantId: 'tenant-a', userId: 'user-1', action: 'security.client_tenant_mismatch' }),
|
||||
});
|
||||
});
|
||||
|
||||
it('does not accept an admin cookie for a client route', async () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Injectable, NestMiddleware, UnauthorizedException } from '@nestjs/common';
|
||||
import { ForbiddenException, Injectable, NestMiddleware, UnauthorizedException } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { AuthSessionRecord, SessionPortal, SessionService } from './session.service';
|
||||
|
||||
@@ -8,6 +8,7 @@ export type SessionRequest = {
|
||||
url?: string;
|
||||
sessionUserId?: string;
|
||||
sessionToken?: string;
|
||||
sessionTenantId?: string;
|
||||
authSession?: AuthSessionRecord;
|
||||
};
|
||||
|
||||
@@ -38,7 +39,7 @@ export class SessionValidationMiddleware implements NestMiddleware {
|
||||
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id: result.record.userId },
|
||||
select: { id: true, status: true, deletedAt: true, sessionVersion: true },
|
||||
select: { id: true, tenantId: true, status: true, deletedAt: true, sessionVersion: true },
|
||||
});
|
||||
if (!user || user.status !== 'active' || user.deletedAt || user.sessionVersion !== result.record.sessionVersion) {
|
||||
await this.sessions.remove(token);
|
||||
@@ -51,6 +52,25 @@ export class SessionValidationMiddleware implements NestMiddleware {
|
||||
request.sessionUserId = user.id;
|
||||
request.sessionToken = token;
|
||||
request.authSession = result.record;
|
||||
if (portal === 'client') {
|
||||
if (!user.tenantId) {
|
||||
throw new ForbiddenException({ code: 'CLIENT_TENANT_REQUIRED', message: '当前客户端账号未关联企业' });
|
||||
}
|
||||
const suppliedTenantId = request.header('x-tenant-id')?.trim();
|
||||
if (suppliedTenantId && suppliedTenantId !== user.tenantId) {
|
||||
await this.prisma.operationLog.create({
|
||||
data: {
|
||||
tenantId: user.tenantId,
|
||||
userId: user.id,
|
||||
action: 'security.client_tenant_mismatch',
|
||||
resource: 'auth_session',
|
||||
detail: { suppliedTenantId },
|
||||
},
|
||||
});
|
||||
throw new ForbiddenException({ code: 'CLIENT_TENANT_MISMATCH', message: '请求企业与登录企业不一致' });
|
||||
}
|
||||
request.sessionTenantId = user.tenantId;
|
||||
}
|
||||
const isSessionRecoveryRoute = /\/auth\/(?:session(?:\/unlock)?|logout)(?:\?|$)/.test(path);
|
||||
if (result.status === 'locked' && !isSessionRecoveryRoute) {
|
||||
if (result.newlyLocked) {
|
||||
|
||||
@@ -21,6 +21,9 @@ export type SessionValidationResult =
|
||||
| { status: 'expired'; code: 'SESSION_INVALID' | 'SESSION_ABSOLUTE_TIMEOUT' | 'SESSION_LOCK_TIMEOUT' };
|
||||
|
||||
const SESSION_PREFIX = 'cmpp:auth:session:';
|
||||
const CAPTCHA_PREFIX = 'cmpp:auth:captcha:';
|
||||
const ANONYMOUS_FAILURE_PREFIX = 'cmpp:auth:failure:';
|
||||
const ANONYMOUS_LOCK_PREFIX = 'cmpp:auth:lock:';
|
||||
export const SESSION_COOKIE_NAME = '__Host-cmpp_session';
|
||||
export const DEVELOPMENT_SESSION_COOKIE_NAME = 'cmpp_session';
|
||||
export const ADMIN_SESSION_COOKIE_NAME = '__Host-cmpp_admin_session';
|
||||
@@ -123,6 +126,61 @@ export class SessionService implements OnModuleDestroy {
|
||||
return this.client.del(this.key(token));
|
||||
}
|
||||
|
||||
async storeCaptcha(captchaId: string, answer: string, ttlSeconds: number) {
|
||||
try {
|
||||
await this.client.set(`${CAPTCHA_PREFIX}${captchaId}`, answer, 'EX', ttlSeconds);
|
||||
} catch {
|
||||
throw new ServiceUnavailableException('验证码服务暂不可用');
|
||||
}
|
||||
}
|
||||
|
||||
async consumeCaptcha(captchaId: string) {
|
||||
try {
|
||||
return await this.client.getdel(`${CAPTCHA_PREFIX}${captchaId}`);
|
||||
} catch {
|
||||
throw new ServiceUnavailableException('验证码服务暂不可用');
|
||||
}
|
||||
}
|
||||
|
||||
async isAnonymousLoginLocked(login: string) {
|
||||
try {
|
||||
return Boolean(await this.client.exists(`${ANONYMOUS_LOCK_PREFIX}${this.loginDigest(login)}`));
|
||||
} catch {
|
||||
throw new ServiceUnavailableException('登录保护服务暂不可用');
|
||||
}
|
||||
}
|
||||
|
||||
async recordAnonymousLoginFailure(login: string) {
|
||||
const digest = this.loginDigest(login);
|
||||
const failureKey = `${ANONYMOUS_FAILURE_PREFIX}${digest}`;
|
||||
const lockKey = `${ANONYMOUS_LOCK_PREFIX}${digest}`;
|
||||
try {
|
||||
const count = Number(await this.client.eval(
|
||||
`local count = redis.call('INCR', KEYS[1])
|
||||
if count == 1 then redis.call('EXPIRE', KEYS[1], ARGV[1]) end
|
||||
if count >= tonumber(ARGV[2]) then redis.call('SET', KEYS[2], '1', 'EX', ARGV[1]) end
|
||||
return count`,
|
||||
2,
|
||||
failureKey,
|
||||
lockKey,
|
||||
24 * 60 * 60,
|
||||
5,
|
||||
));
|
||||
return count;
|
||||
} catch {
|
||||
throw new ServiceUnavailableException('登录保护服务暂不可用');
|
||||
}
|
||||
}
|
||||
|
||||
async clearAnonymousLoginFailures(login: string) {
|
||||
const digest = this.loginDigest(login);
|
||||
try {
|
||||
await this.client.del(`${ANONYMOUS_FAILURE_PREFIX}${digest}`, `${ANONYMOUS_LOCK_PREFIX}${digest}`);
|
||||
} catch {
|
||||
throw new ServiceUnavailableException('登录保护服务暂不可用');
|
||||
}
|
||||
}
|
||||
|
||||
isRecentlyAuthenticated(record: AuthSessionRecord) {
|
||||
return Date.now() - record.lastAuthenticatedAt < this.recentAuthenticationMs;
|
||||
}
|
||||
@@ -195,6 +253,10 @@ export class SessionService implements OnModuleDestroy {
|
||||
return `${SESSION_PREFIX}${createHash('sha256').update(token).digest('hex')}`;
|
||||
}
|
||||
|
||||
private loginDigest(login: string) {
|
||||
return createHash('sha256').update(login.trim().toLocaleLowerCase('en-US')).digest('hex');
|
||||
}
|
||||
|
||||
private get client() {
|
||||
if (!this.redis) {
|
||||
this.redis = new IORedis(process.env.REDIS_URL ?? 'redis://127.0.0.1:6379', {
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { Body, Controller, Get, Param, Post, Query } from '@nestjs/common';
|
||||
import { Body, Controller, Get, Param, Post, Query, UsePipes } from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { TenantId } from '../common/tenant-id.decorator';
|
||||
import { CurrentTenantId } from '../auth/current-tenant-id.decorator';
|
||||
import { ClientBillingEstimateDto } from '../common/client-write.dto';
|
||||
import { strictValidationPipe } from '../common/strict-validation.pipe';
|
||||
import { RequireRecentAuthentication } from '../auth/require-recent-authentication.decorator';
|
||||
import { CurrentSessionUserId } from '../auth/current-session-user.decorator';
|
||||
import {
|
||||
@@ -125,14 +128,15 @@ export class ClientBillingController {
|
||||
constructor(private readonly billing: BillingService) {}
|
||||
|
||||
@Get('orders')
|
||||
listRechargeOrders(@TenantId() tenantId?: string, @Query('page') page?: string, @Query('pageSize') pageSize?: string) {
|
||||
listRechargeOrders(@CurrentTenantId() tenantId: string, @Query('page') page?: string, @Query('pageSize') pageSize?: string) {
|
||||
return page || pageSize
|
||||
? this.billing.listRechargeOrdersPage({ tenantId, page: Number(page), pageSize: Number(pageSize) })
|
||||
: this.billing.listRechargeOrders(tenantId);
|
||||
}
|
||||
|
||||
@Post('estimate')
|
||||
estimateSmsCost(@Body() body: EstimateSmsCostDto) {
|
||||
return this.billing.estimateSmsCost(body);
|
||||
@UsePipes(strictValidationPipe)
|
||||
estimateSmsCost(@CurrentTenantId() tenantId: string, @Body() body: ClientBillingEstimateDto) {
|
||||
return this.billing.estimateSmsCost({ ...body, tenantId });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Body, Controller, Get, Param, Post, Query } from '@nestjs/common';
|
||||
import { Body, Controller, Get, Param, Post, Query, UsePipes } from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { CurrentSessionUserId } from '../auth/current-session-user.decorator';
|
||||
import { TenantId } from '../common/tenant-id.decorator';
|
||||
import { CertificationService, ReviewCertificationDto, SubmitCertificationDto } from './certification.service';
|
||||
import { CurrentTenantId } from '../auth/current-tenant-id.decorator';
|
||||
import { ClientCertificationSubmissionDto } from '../common/client-write.dto';
|
||||
import { strictValidationPipe } from '../common/strict-validation.pipe';
|
||||
import { CertificationService, ReviewCertificationDto } from './certification.service';
|
||||
|
||||
@ApiTags('client-certification')
|
||||
@Controller('client/enterprise-certification')
|
||||
@@ -10,13 +12,14 @@ export class ClientCertificationController {
|
||||
constructor(private readonly certifications: CertificationService) {}
|
||||
|
||||
@Get()
|
||||
list(@TenantId() tenantId?: string) {
|
||||
list(@CurrentTenantId() tenantId: string) {
|
||||
return this.certifications.list(tenantId);
|
||||
}
|
||||
|
||||
@Post()
|
||||
submit(@Body() body: SubmitCertificationDto) {
|
||||
return this.certifications.submit(body);
|
||||
@UsePipes(strictValidationPipe)
|
||||
submit(@CurrentTenantId() tenantId: string, @Body() body: ClientCertificationSubmissionDto) {
|
||||
return this.certifications.submit({ ...body, tenantId });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { ClientBatchTaskDto, ClientImportConfirmDto, ClientStatusChangeDto } from './client-write.dto';
|
||||
import { strictValidationPipe } from './strict-validation.pipe';
|
||||
|
||||
function validate<T>(metatype: new () => T, value: unknown) {
|
||||
return strictValidationPipe.transform(value, { type: 'body', metatype, data: undefined });
|
||||
}
|
||||
|
||||
describe('strict client write DTOs', () => {
|
||||
it('accepts an import confirmation without a client-supplied phones array', async () => {
|
||||
await expect(validate(ClientImportConfirmDto, {
|
||||
content: '【测试】验证码 ${code}',
|
||||
importContent: 'phone,code\n13800000001,1234',
|
||||
})).resolves.toEqual(expect.objectContaining({ importContent: expect.any(String) }));
|
||||
});
|
||||
|
||||
it('rejects a direct batch task without validated phone numbers', async () => {
|
||||
await expect(validate(ClientBatchTaskDto, { content: '【测试】通知' })).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('rejects a client-supplied operator identity', async () => {
|
||||
await expect(validate(ClientStatusChangeDto, { status: 'disabled', operatorId: 'another-user' }))
|
||||
.rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,154 @@
|
||||
import { Type } from 'class-transformer';
|
||||
import { PartialType } from '@nestjs/swagger';
|
||||
import {
|
||||
ArrayMaxSize,
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsIn,
|
||||
IsInt,
|
||||
IsObject,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUrl,
|
||||
Matches,
|
||||
Max,
|
||||
MaxLength,
|
||||
Min,
|
||||
MinLength,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
|
||||
export class ClientCertificationSubmissionDto {
|
||||
@IsOptional() @IsString() @MaxLength(64) tenantId?: string;
|
||||
@IsString() @MinLength(1) @MaxLength(200) companyName!: string;
|
||||
@IsOptional() @IsString() @MaxLength(100) licenseNo?: string;
|
||||
@IsOptional() @IsString() @MaxLength(100) contactName?: string;
|
||||
@IsOptional() @Matches(/^\+?[0-9-]{6,24}$/) contactPhone?: string;
|
||||
@IsOptional() @IsObject() materials?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export class ClientTaskBaseDto {
|
||||
@IsOptional() @IsString() @MaxLength(64) tenantId?: string;
|
||||
@IsOptional() @IsString() @MaxLength(64) applicationId?: string;
|
||||
@IsOptional() @IsString() @MaxLength(64) templateId?: string;
|
||||
@IsString() @MinLength(1) @MaxLength(5000) content!: string;
|
||||
@IsOptional() @IsString() @MaxLength(64) category?: string;
|
||||
@IsOptional() @IsIn(['immediate', 'scheduled']) sendMode?: 'immediate' | 'scheduled';
|
||||
@IsOptional() @IsString() @MaxLength(64) scheduledAt?: string;
|
||||
@IsOptional() @IsObject() variables?: Record<string, unknown>;
|
||||
@IsOptional() @IsString() @MaxLength(64) requestedAt?: string;
|
||||
@IsOptional() @IsString() @MaxLength(128) clientMessageId?: string;
|
||||
}
|
||||
|
||||
export class ClientBatchTaskDto extends ClientTaskBaseDto {
|
||||
@IsArray() @ArrayMaxSize(100000) @Matches(/^1\d{10}$/, { each: true }) phones!: string[];
|
||||
}
|
||||
|
||||
export class ClientImportPreviewDto {
|
||||
@IsOptional() @IsString() @MaxLength(64) tenantId?: string;
|
||||
@IsOptional() @IsString() @MaxLength(64) applicationId?: string;
|
||||
@IsString() @MinLength(1) @MaxLength(5_000_000) content!: string;
|
||||
@IsOptional() @IsString() @MaxLength(255) fileName?: string;
|
||||
@IsOptional() @IsIn(['utf8', 'gbk']) encoding?: 'utf8' | 'gbk';
|
||||
@IsOptional() @IsIn([',', '\t']) delimiter?: ',' | '\t';
|
||||
@IsOptional() @IsArray() @ArrayMaxSize(100) @IsString({ each: true }) requiredVariables?: string[];
|
||||
}
|
||||
|
||||
export class ClientImportConfirmDto extends ClientTaskBaseDto {
|
||||
@IsString() @MinLength(1) @MaxLength(5_000_000) importContent!: string;
|
||||
@IsOptional() @IsArray() @ArrayMaxSize(100) @IsString({ each: true }) requiredVariables?: string[];
|
||||
}
|
||||
|
||||
export class ClientBillingEstimateDto {
|
||||
@IsOptional() @IsString() @MaxLength(64) tenantId?: string;
|
||||
@IsOptional() @IsString() @MaxLength(64) applicationId?: string;
|
||||
@IsString() @MinLength(1) @MaxLength(5000) content!: string;
|
||||
@Type(() => Number) @IsInt() @Min(1) @Max(100000) phoneCount!: number;
|
||||
@IsOptional() @Type(() => Number) @Min(0) unitPrice?: number;
|
||||
@IsOptional() @IsString() @MaxLength(64) taskId?: string;
|
||||
}
|
||||
|
||||
export class ClientSmsApplicationDto {
|
||||
@IsOptional() @IsString() @MaxLength(64) tenantId?: string;
|
||||
@IsString() @MinLength(1) @MaxLength(100) name!: string;
|
||||
@IsOptional() @IsString() @MaxLength(500) scene?: string;
|
||||
@IsOptional() @IsUrl({ require_tld: false }) @MaxLength(2048) callbackUrl?: string;
|
||||
@IsOptional() @IsString() @MaxLength(64) cmppAccount?: string;
|
||||
@IsOptional() @IsString() @MaxLength(64) cmppEnterpriseCode?: string;
|
||||
@IsOptional() @IsString() @MaxLength(32) cmppApplicationExtension?: string;
|
||||
@IsOptional() @IsBoolean() cmppAccessNumberFillEnabled?: boolean;
|
||||
@IsOptional() @IsString() @MaxLength(32) cmppAccessNumberFillPrefix?: string;
|
||||
@IsOptional() @IsString() @MaxLength(4096) passwordCipher?: string;
|
||||
@IsOptional() @IsBoolean() interfaceEnabled?: boolean;
|
||||
@IsOptional() @IsString() @MaxLength(32) interfaceType?: string;
|
||||
@IsOptional() @Type(() => Number) @IsInt() @Min(1) @Max(100) cmppMaxConnections?: number;
|
||||
@IsOptional() @Type(() => Number) @IsInt() @Min(1) @Max(1000) cmppWindowSize?: number;
|
||||
@IsOptional() @Type(() => Number) @IsInt() @Min(0) dailyLimit?: number;
|
||||
@IsOptional() @Type(() => Number) @Min(0) customerUnitPrice?: number;
|
||||
@IsOptional() @IsString() @MaxLength(32) queuePriority?: string;
|
||||
@IsOptional() @IsString() @MaxLength(32) templateMismatchMode?: string;
|
||||
@IsOptional() @IsBoolean() downstreamReceiptRetryEnabled?: boolean;
|
||||
@IsOptional() @IsBoolean() downstreamUplinkRetryEnabled?: boolean;
|
||||
@IsOptional() @IsArray() @ArrayMaxSize(100) @IsString({ each: true }) ipAllowlist?: string[];
|
||||
}
|
||||
|
||||
export class ClientSmsSignatureDto {
|
||||
@IsOptional() @IsString() @MaxLength(64) tenantId?: string;
|
||||
@IsOptional() @IsString() @MaxLength(64) applicationId?: string;
|
||||
@IsString() @MinLength(1) @MaxLength(100) name!: string;
|
||||
@IsOptional() @IsString() @MaxLength(500) purpose?: string;
|
||||
@IsOptional() @IsObject() drainageInfo?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export class ClientSmsSignatureUpdateDto extends PartialType(ClientSmsSignatureDto) {
|
||||
@IsOptional() @IsString() @MaxLength(32) auditStatus?: string;
|
||||
}
|
||||
|
||||
export class ClientDrainageInfoDto {
|
||||
@IsString() @MinLength(1) @MaxLength(200) siteName!: string;
|
||||
@IsUrl({ require_tld: false }) @MaxLength(2048) url!: string;
|
||||
@IsOptional() @IsString() @MaxLength(1000) remark?: string;
|
||||
@IsOptional() @IsObject() reportValues?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export class ClientDrainageInfoUpdateDto extends PartialType(ClientDrainageInfoDto) {}
|
||||
|
||||
export class ClientSignatureMaterialDto {
|
||||
@IsOptional() @IsString() @MaxLength(64) fileObjectId?: string;
|
||||
@IsString() @MinLength(1) @MaxLength(64) materialType!: string;
|
||||
@IsString() @MinLength(1) @MaxLength(200) title!: string;
|
||||
@IsOptional() @IsString() @MaxLength(2000) description?: string;
|
||||
}
|
||||
|
||||
class TemplateVariableDto {
|
||||
@IsString() @MinLength(1) @MaxLength(64) name!: string;
|
||||
@IsOptional() @IsString() @MaxLength(500) example?: string;
|
||||
@IsOptional() @IsBoolean() required?: boolean;
|
||||
}
|
||||
|
||||
export class ClientSmsTemplateDto {
|
||||
@IsOptional() @IsString() @MaxLength(64) tenantId?: string;
|
||||
@IsString() @MaxLength(64) applicationId!: string;
|
||||
@IsOptional() @IsString() @MaxLength(64) signatureId?: string;
|
||||
@IsString() @MinLength(1) @MaxLength(200) name!: string;
|
||||
@IsString() @MinLength(1) @MaxLength(5000) content!: string;
|
||||
@IsOptional() @IsString() @MaxLength(64) category?: string;
|
||||
@IsOptional() @IsArray() @ArrayMaxSize(100) @ValidateNested({ each: true }) @Type(() => TemplateVariableDto) variables?: TemplateVariableDto[];
|
||||
}
|
||||
|
||||
export class ClientSmsTemplateUpdateDto extends PartialType(ClientSmsTemplateDto) {
|
||||
@IsOptional() @IsString() @MaxLength(32) auditStatus?: string;
|
||||
}
|
||||
|
||||
export class ClientStatusChangeDto {
|
||||
@IsOptional() @IsString() @MaxLength(32) status?: string;
|
||||
@IsOptional() @IsString() @MaxLength(1000) reason?: string;
|
||||
@IsOptional() @IsBoolean() force?: boolean;
|
||||
@IsOptional() @IsString() @MaxLength(200) confirmName?: string;
|
||||
@IsOptional() @IsString() @MaxLength(200) confirmText?: string;
|
||||
@IsOptional() @IsString() @MaxLength(64) expectedUpdatedAt?: string;
|
||||
@IsOptional() @IsString() @MaxLength(128) idempotencyKey?: string;
|
||||
@IsOptional() @IsBoolean() deleteAssociatedTemplates?: boolean;
|
||||
@IsOptional() @IsBoolean() deleteAssociatedDrainage?: boolean;
|
||||
@IsOptional() @IsBoolean() abandonAssociatedReportTasks?: boolean;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { ValidationPipe } from '@nestjs/common';
|
||||
|
||||
export const strictValidationPipe = new ValidationPipe({
|
||||
transform: true,
|
||||
whitelist: true,
|
||||
forbidNonWhitelisted: true,
|
||||
stopAtFirstError: false,
|
||||
transformOptions: { enableImplicitConversion: false },
|
||||
});
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Body, Controller, Get, Param, Post } from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { CurrentSessionUserId } from '../auth/current-session-user.decorator';
|
||||
import { CurrentTenantId } from '../auth/current-tenant-id.decorator';
|
||||
import { RequireRecentAuthentication } from '../auth/require-recent-authentication.decorator';
|
||||
import { TenantId } from '../common/tenant-id.decorator';
|
||||
import { DeleteTargetDto, DeletionGovernanceService, DeletionTargetType } from './deletion-governance.service';
|
||||
|
||||
@ApiTags('deletion-governance')
|
||||
@@ -28,13 +28,13 @@ export class ClientDeletionGovernanceController {
|
||||
constructor(private readonly deletions: DeletionGovernanceService) {}
|
||||
|
||||
@Get(':type/:id/preflight')
|
||||
preflight(@Param('type') type: DeletionTargetType, @Param('id') id: string, @TenantId() tenantId?: string) {
|
||||
preflight(@Param('type') type: DeletionTargetType, @Param('id') id: string, @CurrentTenantId() tenantId: string) {
|
||||
return this.deletions.preflight(type, id, tenantId);
|
||||
}
|
||||
|
||||
@Post(':type/:id')
|
||||
@RequireRecentAuthentication()
|
||||
delete(@Param('type') type: DeletionTargetType, @Param('id') id: string, @Body() body: DeleteTargetDto, @TenantId() tenantId?: string, @CurrentSessionUserId() operatorId?: string) {
|
||||
delete(@Param('type') type: DeletionTargetType, @Param('id') id: string, @Body() body: DeleteTargetDto, @CurrentTenantId() tenantId: string, @CurrentSessionUserId() operatorId?: string) {
|
||||
return this.deletions.delete(type, id, { ...body, operatorId }, tenantId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Body, Controller, Get, Param, Post, Put } from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { RequireRecentAuthentication } from '../auth/require-recent-authentication.decorator';
|
||||
import { TenantId } from '../common/tenant-id.decorator';
|
||||
import { CurrentTenantId } from '../auth/current-tenant-id.decorator';
|
||||
import { CurrentSessionUserId } from '../auth/current-session-user.decorator';
|
||||
import { OpenApiService } from './open-api.service';
|
||||
|
||||
@ApiTags('client-http-open-api-management')
|
||||
@@ -9,13 +10,13 @@ import { OpenApiService } from './open-api.service';
|
||||
export class ClientOpenApiController {
|
||||
constructor(private readonly service: OpenApiService) {}
|
||||
|
||||
@Get() getConfig(@Param('applicationId') applicationId: string, @TenantId() tenantId?: string) { return this.service.getConfig(applicationId, tenantId); }
|
||||
@Get('credentials') listCredentials(@Param('applicationId') applicationId: string, @TenantId() tenantId?: string) { return this.service.listCredentials(applicationId, tenantId); }
|
||||
@Post('credentials') @RequireRecentAuthentication() createCredential(@Param('applicationId') applicationId: string, @Body() body: { name?: string; expiresAt?: string; createdById?: string }, @TenantId() tenantId?: string) { return this.service.createCredential(applicationId, body, tenantId, true); }
|
||||
@Post('credentials/:credentialId/revoke') @RequireRecentAuthentication() revokeCredential(@Param('applicationId') applicationId: string, @Param('credentialId') credentialId: string, @TenantId() tenantId?: string) { return this.service.revokeCredential(applicationId, credentialId, tenantId); }
|
||||
@Get('webhooks') getWebhooks(@Param('applicationId') applicationId: string, @TenantId() tenantId?: string) { return this.service.getWebhookEndpoints(applicationId, tenantId); }
|
||||
@Put('webhooks/:eventType') @RequireRecentAuthentication() upsertWebhook(@Param('applicationId') applicationId: string, @Param('eventType') eventType: string, @Body() body: { url: string; rotateSecret?: boolean; status?: string }, @TenantId() tenantId?: string) { return this.service.upsertWebhookEndpoint(applicationId, eventType, body, tenantId); }
|
||||
@Get('requests') listRequests(@Param('applicationId') applicationId: string, @TenantId() tenantId?: string) { return this.service.listRequestLogs(applicationId, tenantId); }
|
||||
@Get('webhook-deliveries') listDeliveries(@Param('applicationId') applicationId: string, @TenantId() tenantId?: string) { return this.service.listWebhookDeliveries(applicationId, tenantId); }
|
||||
@Post('webhook-deliveries/:deliveryId/retry') @RequireRecentAuthentication() retryDelivery(@Param('applicationId') applicationId: string, @Param('deliveryId') deliveryId: string, @TenantId() tenantId?: string) { return this.service.retryWebhookDelivery(applicationId, deliveryId, tenantId); }
|
||||
@Get() getConfig(@Param('applicationId') applicationId: string, @CurrentTenantId() tenantId: string) { return this.service.getConfig(applicationId, tenantId); }
|
||||
@Get('credentials') listCredentials(@Param('applicationId') applicationId: string, @CurrentTenantId() tenantId: string) { return this.service.listCredentials(applicationId, tenantId); }
|
||||
@Post('credentials') @RequireRecentAuthentication() createCredential(@Param('applicationId') applicationId: string, @Body() body: { name?: string; expiresAt?: string; createdById?: string }, @CurrentTenantId() tenantId: string, @CurrentSessionUserId() createdById?: string) { return this.service.createCredential(applicationId, { ...body, createdById }, tenantId, true); }
|
||||
@Post('credentials/:credentialId/revoke') @RequireRecentAuthentication() revokeCredential(@Param('applicationId') applicationId: string, @Param('credentialId') credentialId: string, @CurrentTenantId() tenantId: string) { return this.service.revokeCredential(applicationId, credentialId, tenantId); }
|
||||
@Get('webhooks') getWebhooks(@Param('applicationId') applicationId: string, @CurrentTenantId() tenantId: string) { return this.service.getWebhookEndpoints(applicationId, tenantId); }
|
||||
@Put('webhooks/:eventType') @RequireRecentAuthentication() upsertWebhook(@Param('applicationId') applicationId: string, @Param('eventType') eventType: string, @Body() body: { url: string; rotateSecret?: boolean; status?: string }, @CurrentTenantId() tenantId: string) { return this.service.upsertWebhookEndpoint(applicationId, eventType, body, tenantId); }
|
||||
@Get('requests') listRequests(@Param('applicationId') applicationId: string, @CurrentTenantId() tenantId: string) { return this.service.listRequestLogs(applicationId, tenantId); }
|
||||
@Get('webhook-deliveries') listDeliveries(@Param('applicationId') applicationId: string, @CurrentTenantId() tenantId: string) { return this.service.listWebhookDeliveries(applicationId, tenantId); }
|
||||
@Post('webhook-deliveries/:deliveryId/retry') @RequireRecentAuthentication() retryDelivery(@Param('applicationId') applicationId: string, @Param('deliveryId') deliveryId: string, @CurrentTenantId() tenantId: string) { return this.service.retryWebhookDelivery(applicationId, deliveryId, tenantId); }
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Body, Controller, Get, Param, Post, Query } from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { CurrentSessionUserId } from '../auth/current-session-user.decorator';
|
||||
import { TenantId } from '../common/tenant-id.decorator';
|
||||
import { CurrentTenantId } from '../auth/current-tenant-id.decorator';
|
||||
import { OperationsService } from './operations.service';
|
||||
|
||||
@ApiTags('client-operations')
|
||||
@@ -10,18 +10,18 @@ export class ClientOperationsController {
|
||||
constructor(private readonly operations: OperationsService) {}
|
||||
|
||||
@Get('batch-tasks')
|
||||
listBatchTasks(@TenantId() tenantId?: string, @Query('status') status?: string) {
|
||||
listBatchTasks(@CurrentTenantId() tenantId: string, @Query('status') status?: string) {
|
||||
return this.operations.listClientBatchTasks({ tenantId, status });
|
||||
}
|
||||
|
||||
@Get('batch-tasks/:id/messages')
|
||||
listTaskMessages(@TenantId() tenantId: string | undefined, @Param('id') taskId: string, @Query('phoneNumber') phoneNumber?: string) {
|
||||
listTaskMessages(@CurrentTenantId() tenantId: string, @Param('id') taskId: string, @Query('phoneNumber') phoneNumber?: string) {
|
||||
return this.operations.listClientMessages({ tenantId, taskId, phoneNumber });
|
||||
}
|
||||
|
||||
@Get('messages')
|
||||
listMessages(
|
||||
@TenantId() tenantId?: string,
|
||||
@CurrentTenantId() tenantId: string,
|
||||
@Query('applicationId') applicationId?: string,
|
||||
@Query('taskId') taskId?: string,
|
||||
@Query('messageId') messageId?: string,
|
||||
@@ -49,20 +49,20 @@ export class ClientOperationsController {
|
||||
}
|
||||
|
||||
@Get('uplink-messages')
|
||||
listUplinkMessages(@TenantId() tenantId?: string, @Query('channelId') channelId?: string, @Query('applicationId') applicationId?: string, @Query('phoneNumber') phoneNumber?: string, @Query('keyword') keyword?: string, @Query('startTime') startTime?: string, @Query('endTime') endTime?: string, @Query('page') page?: string, @Query('pageSize') pageSize?: string) {
|
||||
listUplinkMessages(@CurrentTenantId() tenantId: string, @Query('channelId') channelId?: string, @Query('applicationId') applicationId?: string, @Query('phoneNumber') phoneNumber?: string, @Query('keyword') keyword?: string, @Query('startTime') startTime?: string, @Query('endTime') endTime?: string, @Query('page') page?: string, @Query('pageSize') pageSize?: string) {
|
||||
return page || pageSize
|
||||
? this.operations.listUplinkMessagesPage({ tenantId, applicationId, phoneNumber, keyword, startTime, endTime, page: Number(page), pageSize: Number(pageSize) }, true)
|
||||
: this.operations.listClientUplinkMessages({ tenantId, applicationId, phoneNumber, keyword, startTime, endTime });
|
||||
}
|
||||
|
||||
@Get('dashboard')
|
||||
dashboard(@TenantId() tenantId?: string) {
|
||||
dashboard(@CurrentTenantId() tenantId: string) {
|
||||
return this.operations.clientDashboard({ tenantId });
|
||||
}
|
||||
|
||||
@Get('system-logs')
|
||||
systemLogs(
|
||||
@TenantId() tenantId?: string,
|
||||
@CurrentTenantId() tenantId: string,
|
||||
@Query('keyword') keyword?: string,
|
||||
@Query('level') level?: string,
|
||||
@Query('module') module?: string,
|
||||
@@ -78,8 +78,9 @@ export class ClientOperationsController {
|
||||
@Post('system-logs/exports')
|
||||
exportSystemLogs(
|
||||
@CurrentSessionUserId() userId: string | undefined,
|
||||
@CurrentTenantId() tenantId: string,
|
||||
@Body() body: { keyword?: string; level?: string; module?: string; range?: string; createdAtFrom?: string; createdAtTo?: string },
|
||||
) {
|
||||
return this.operations.exportSystemLogs(body, userId);
|
||||
return this.operations.exportSystemLogs({ ...body, tenantId }, userId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +36,10 @@ export class PrismaService extends PrismaClient implements OnModuleDestroy {
|
||||
const poolMax = Number.isInteger(configuredPoolMax) && configuredPoolMax > 0
|
||||
? configuredPoolMax
|
||||
: protocolLogRole ? 4 : outboxRole ? 6 : callbackRole ? 16 : workerRole ? 8 : 32;
|
||||
const allowDevelopmentDefault = process.env.NODE_ENV === 'development' || process.env.NODE_ENV === 'test';
|
||||
if (!databaseUrl && !allowDevelopmentDefault) {
|
||||
throw new Error(`DATABASE_URL is required for CMPP process role ${processRole}`);
|
||||
}
|
||||
const databasePool = new Pool({
|
||||
connectionString: databaseUrl
|
||||
?? 'postgresql://cmpp:cmpp_password@localhost:5432/cmpp_platform?schema=public',
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { Body, Controller, Get, Post, Query } from '@nestjs/common';
|
||||
import { Body, Controller, Get, Post, Query, UsePipes } from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { TenantId } from '../common/tenant-id.decorator';
|
||||
import { EvaluateSmsTaskDto, RiskReviewService } from './risk-review.service';
|
||||
import { CurrentTenantId } from '../auth/current-tenant-id.decorator';
|
||||
import { CurrentSessionUserId } from '../auth/current-session-user.decorator';
|
||||
import { ClientBatchTaskDto } from '../common/client-write.dto';
|
||||
import { strictValidationPipe } from '../common/strict-validation.pipe';
|
||||
import { RiskReviewService } from './risk-review.service';
|
||||
|
||||
@ApiTags('client-risk-review')
|
||||
@Controller('client/risk-review')
|
||||
@@ -9,13 +12,13 @@ export class ClientRiskReviewController {
|
||||
constructor(private readonly riskReview: RiskReviewService) {}
|
||||
|
||||
@Post('tasks/evaluate')
|
||||
evaluateTask(@Body() body: EvaluateSmsTaskDto) {
|
||||
return this.riskReview.evaluateTask(body);
|
||||
@UsePipes(strictValidationPipe)
|
||||
evaluateTask(@CurrentTenantId() tenantId: string, @CurrentSessionUserId() createdById: string | undefined, @Body() body: ClientBatchTaskDto) {
|
||||
return this.riskReview.evaluateTask({ ...body, tenantId, createdById });
|
||||
}
|
||||
|
||||
@Get('tasks')
|
||||
listTasks(@TenantId() tenantId?: string, @Query('status') status?: string) {
|
||||
listTasks(@CurrentTenantId() tenantId: string, @Query('status') status?: string) {
|
||||
return this.riskReview.listTasks(tenantId, status);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { BadRequestException, Body, Controller, Get, Param, Post, Query } from '@nestjs/common';
|
||||
import { Body, Controller, Get, Param, Post, Query, UsePipes } from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { TenantId } from '../common/tenant-id.decorator';
|
||||
import { ConfirmImportDto, CreateBatchTaskDto, ImportPreviewDto } from './send-chain.contracts';
|
||||
import { CurrentTenantId } from '../auth/current-tenant-id.decorator';
|
||||
import { CurrentSessionUserId } from '../auth/current-session-user.decorator';
|
||||
import { ClientBatchTaskDto, ClientImportConfirmDto, ClientImportPreviewDto } from '../common/client-write.dto';
|
||||
import { strictValidationPipe } from '../common/strict-validation.pipe';
|
||||
import { SendChainService } from './send-chain.service';
|
||||
|
||||
@ApiTags('client-send-chain')
|
||||
@@ -10,46 +12,42 @@ export class ClientSendChainController {
|
||||
constructor(private readonly sendChain: SendChainService) {}
|
||||
|
||||
@Post('batch-tasks')
|
||||
createBatchTask(@Body() body: CreateBatchTaskDto) {
|
||||
return this.sendChain.createBatchTask(body);
|
||||
@UsePipes(strictValidationPipe)
|
||||
createBatchTask(@CurrentTenantId() tenantId: string, @CurrentSessionUserId() createdById: string | undefined, @Body() body: ClientBatchTaskDto) {
|
||||
return this.sendChain.createBatchTask({ ...body, tenantId, sourceType: 'client', createdById });
|
||||
}
|
||||
|
||||
@Post('imports/preview')
|
||||
previewImport(@Body() body: ImportPreviewDto) {
|
||||
return this.sendChain.previewImport(body);
|
||||
@UsePipes(strictValidationPipe)
|
||||
previewImport(@CurrentTenantId() tenantId: string, @Body() body: ClientImportPreviewDto) {
|
||||
return this.sendChain.previewImport({ ...body, tenantId });
|
||||
}
|
||||
|
||||
@Post('imports/confirm')
|
||||
confirmImport(@Body() body: ConfirmImportDto) {
|
||||
return this.sendChain.confirmImport(body);
|
||||
@UsePipes(strictValidationPipe)
|
||||
confirmImport(@CurrentTenantId() tenantId: string, @CurrentSessionUserId() createdById: string | undefined, @Body() body: ClientImportConfirmDto) {
|
||||
return this.sendChain.confirmImport({ ...body, tenantId, sourceType: 'client', createdById });
|
||||
}
|
||||
|
||||
@Get('batch-tasks')
|
||||
listBatchTasks(@TenantId() tenantId?: string, @Query('status') status?: string, @Query('keyword') keyword?: string, @Query('applicationKeyword') applicationKeyword?: string, @Query('createdAtFrom') createdAtFrom?: string, @Query('createdAtTo') createdAtTo?: string, @Query('page') page?: string, @Query('pageSize') pageSize?: string) {
|
||||
listBatchTasks(@CurrentTenantId() tenantId: string, @Query('status') status?: string, @Query('keyword') keyword?: string, @Query('applicationKeyword') applicationKeyword?: string, @Query('createdAtFrom') createdAtFrom?: string, @Query('createdAtTo') createdAtTo?: string, @Query('page') page?: string, @Query('pageSize') pageSize?: string) {
|
||||
return page || pageSize
|
||||
? this.sendChain.listBatchTasksPage({ tenantId: requireTenantId(tenantId), status, sourceType: 'client', keyword, applicationKeyword, createdAtFrom, createdAtTo, page: Number(page), pageSize: Number(pageSize) })
|
||||
: this.sendChain.listBatchTasks(requireTenantId(tenantId), status, 'client');
|
||||
? this.sendChain.listBatchTasksPage({ tenantId, status, sourceType: 'client', keyword, applicationKeyword, createdAtFrom, createdAtTo, page: Number(page), pageSize: Number(pageSize) })
|
||||
: this.sendChain.listBatchTasks(tenantId, status, 'client');
|
||||
}
|
||||
|
||||
@Get('batch-tasks/:id')
|
||||
getBatchTask(@TenantId() tenantId: string | undefined, @Param('id') taskId: string) {
|
||||
return this.sendChain.getBatchTask(taskId, requireTenantId(tenantId), 'client');
|
||||
getBatchTask(@CurrentTenantId() tenantId: string, @Param('id') taskId: string) {
|
||||
return this.sendChain.getBatchTask(taskId, tenantId, 'client');
|
||||
}
|
||||
|
||||
@Get('batch-tasks/:id/messages')
|
||||
listTaskMessages(@TenantId() tenantId: string | undefined, @Param('id') taskId: string) {
|
||||
return this.sendChain.listClientTaskMessages(taskId, requireTenantId(tenantId));
|
||||
listTaskMessages(@CurrentTenantId() tenantId: string, @Param('id') taskId: string) {
|
||||
return this.sendChain.listClientTaskMessages(taskId, tenantId);
|
||||
}
|
||||
|
||||
@Post('batch-tasks/:id/cancel')
|
||||
cancelBatchTask(@TenantId() tenantId: string | undefined, @Param('id') taskId: string) {
|
||||
return this.sendChain.cancelBatchTask(taskId, requireTenantId(tenantId), 'client');
|
||||
cancelBatchTask(@CurrentTenantId() tenantId: string, @Param('id') taskId: string) {
|
||||
return this.sendChain.cancelBatchTask(taskId, tenantId, 'client');
|
||||
}
|
||||
}
|
||||
|
||||
function requireTenantId(tenantId?: string) {
|
||||
if (!tenantId) {
|
||||
throw new BadRequestException('Tenant context is required');
|
||||
}
|
||||
return tenantId;
|
||||
}
|
||||
|
||||
@@ -234,7 +234,7 @@ export interface ImportPreviewDto {
|
||||
requiredVariables?: string[];
|
||||
}
|
||||
|
||||
export interface ConfirmImportDto extends CreateBatchTaskDto {
|
||||
export interface ConfirmImportDto extends Omit<CreateBatchTaskDto, 'phones'> {
|
||||
importContent: string;
|
||||
requiredVariables?: string[];
|
||||
}
|
||||
|
||||
@@ -1,20 +1,11 @@
|
||||
import { Body, Controller, Get, Param, Post, Put, Query } from '@nestjs/common';
|
||||
import { Body, Controller, Get, Param, Post, Put, Query, UsePipes } from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { TenantId } from '../common/tenant-id.decorator';
|
||||
import { CurrentTenantId } from '../auth/current-tenant-id.decorator';
|
||||
import { RequireRecentAuthentication } from '../auth/require-recent-authentication.decorator';
|
||||
import { CurrentSessionUserId } from '../auth/current-session-user.decorator';
|
||||
import { DeleteTargetDto, DeletionGovernanceService } from '../deletion-governance/deletion-governance.service';
|
||||
import {
|
||||
CreateSignatureMaterialDto,
|
||||
CreateSmsApplicationDto,
|
||||
CreateSmsDrainageInfoDto,
|
||||
CreateSmsSignatureDto,
|
||||
CreateSmsTemplateDto,
|
||||
StatusChangeDto,
|
||||
UpdateSmsTemplateDto,
|
||||
UpdateSmsDrainageInfoDto,
|
||||
UpdateSmsSignatureDto,
|
||||
} from './sms-config.contracts';
|
||||
import { ClientDrainageInfoDto, ClientDrainageInfoUpdateDto, ClientSignatureMaterialDto, ClientSmsApplicationDto, ClientSmsSignatureDto, ClientSmsSignatureUpdateDto, ClientSmsTemplateDto, ClientSmsTemplateUpdateDto, ClientStatusChangeDto } from '../common/client-write.dto';
|
||||
import { strictValidationPipe } from '../common/strict-validation.pipe';
|
||||
import { DeletionGovernanceService } from '../deletion-governance/deletion-governance.service';
|
||||
import { SmsConfigService } from './sms-config.service';
|
||||
|
||||
@ApiTags('client-sms-config')
|
||||
@@ -23,29 +14,30 @@ export class ClientSmsConfigController {
|
||||
constructor(private readonly smsConfig: SmsConfigService, private readonly deletions: DeletionGovernanceService) {}
|
||||
|
||||
@Get('applications')
|
||||
listApplications(@TenantId() tenantId?: string, @Query('page') page?: string, @Query('pageSize') pageSize?: string) {
|
||||
listApplications(@CurrentTenantId() tenantId: string, @Query('page') page?: string, @Query('pageSize') pageSize?: string) {
|
||||
return page || pageSize
|
||||
? this.smsConfig.listApplicationsPage({ tenantId, page: Number(page), pageSize: Number(pageSize) })
|
||||
: this.smsConfig.listApplications(tenantId);
|
||||
}
|
||||
|
||||
@Get('application-options')
|
||||
listApplicationOptions(@TenantId() tenantId?: string) {
|
||||
listApplicationOptions(@CurrentTenantId() tenantId: string) {
|
||||
return this.smsConfig.listApplicationOptions(tenantId);
|
||||
}
|
||||
|
||||
@Post('applications')
|
||||
createApplication(@Body() body: CreateSmsApplicationDto) {
|
||||
return this.smsConfig.createApplication(body);
|
||||
@UsePipes(strictValidationPipe)
|
||||
createApplication(@CurrentTenantId() tenantId: string, @Body() body: ClientSmsApplicationDto) {
|
||||
return this.smsConfig.createApplication({ ...body, tenantId });
|
||||
}
|
||||
|
||||
@Get('applications/:id/cmpp-params')
|
||||
getApplicationCmppParams(@Param('id') applicationId: string, @TenantId() tenantId?: string) {
|
||||
getApplicationCmppParams(@Param('id') applicationId: string, @CurrentTenantId() tenantId: string) {
|
||||
return this.smsConfig.getApplicationCmppParams(applicationId, tenantId);
|
||||
}
|
||||
|
||||
@Get('applications/:id/report-fields')
|
||||
getApplicationReportFields(@Param('id') applicationId: string, @Query('reportType') reportType: 'signature' | 'drainage' = 'drainage', @TenantId() tenantId?: string) {
|
||||
getApplicationReportFields(@Param('id') applicationId: string, @Query('reportType') reportType: 'signature' | 'drainage' = 'drainage', @CurrentTenantId() tenantId: string) {
|
||||
return this.smsConfig.getApplication(applicationId, tenantId).then(() => this.smsConfig.getClientApplicationReportFields(applicationId, reportType));
|
||||
}
|
||||
|
||||
@@ -56,110 +48,122 @@ export class ClientSmsConfigController {
|
||||
|
||||
@Post('applications/:id/secret/reset')
|
||||
@RequireRecentAuthentication()
|
||||
resetApplicationSecret(@Param('id') applicationId: string, @Body() body: StatusChangeDto) {
|
||||
return this.smsConfig.resetApplicationSecret(applicationId, body);
|
||||
@UsePipes(strictValidationPipe)
|
||||
resetApplicationSecret(@Param('id') applicationId: string, @Body() body: ClientStatusChangeDto, @CurrentTenantId() tenantId: string, @CurrentSessionUserId() operatorId?: string) {
|
||||
return this.smsConfig.resetClientApplicationSecret(applicationId, { ...body, operatorId }, tenantId);
|
||||
}
|
||||
|
||||
@Post('applications/:id/status')
|
||||
@RequireRecentAuthentication()
|
||||
changeApplicationStatus(@Param('id') applicationId: string, @Body() body: StatusChangeDto) {
|
||||
return this.smsConfig.changeApplicationStatus(applicationId, body);
|
||||
@UsePipes(strictValidationPipe)
|
||||
changeApplicationStatus(@Param('id') applicationId: string, @Body() body: ClientStatusChangeDto, @CurrentTenantId() tenantId: string, @CurrentSessionUserId() operatorId?: string) {
|
||||
return this.smsConfig.changeClientApplicationStatus(applicationId, { ...body, operatorId }, tenantId);
|
||||
}
|
||||
|
||||
@Get('signatures')
|
||||
listSignatures(@TenantId() tenantId?: string) {
|
||||
listSignatures(@CurrentTenantId() tenantId: string) {
|
||||
return this.smsConfig.listClientSignatures(tenantId);
|
||||
}
|
||||
|
||||
@Get('signature-options')
|
||||
listSignatureOptions(@TenantId() tenantId?: string) {
|
||||
listSignatureOptions(@CurrentTenantId() tenantId: string) {
|
||||
return this.smsConfig.listSignatureOptions(tenantId);
|
||||
}
|
||||
|
||||
@Get('signatures-workspace')
|
||||
getSignatureWorkspace(@TenantId() tenantId?: string, @Query('keyword') keyword?: string, @Query('applicationId') applicationId?: string, @Query('status') status?: string, @Query('page') page?: string, @Query('pageSize') pageSize?: string) {
|
||||
getSignatureWorkspace(@CurrentTenantId() tenantId: string, @Query('keyword') keyword?: string, @Query('applicationId') applicationId?: string, @Query('status') status?: string, @Query('page') page?: string, @Query('pageSize') pageSize?: string) {
|
||||
return this.smsConfig.getClientSignatureWorkspace(tenantId, { keyword, applicationId, status, page: Number(page), pageSize: Number(pageSize) });
|
||||
}
|
||||
|
||||
@Post('signatures')
|
||||
async createSignature(@Body() body: CreateSmsSignatureDto, @TenantId() tenantId?: string) {
|
||||
const signature = await this.smsConfig.createSignature({ ...body, tenantId: tenantId ?? body.tenantId });
|
||||
return this.smsConfig.getClientSignatureView(signature.id, tenantId ?? body.tenantId);
|
||||
@UsePipes(strictValidationPipe)
|
||||
async createSignature(@Body() body: ClientSmsSignatureDto, @CurrentTenantId() tenantId: string) {
|
||||
const signature = await this.smsConfig.createSignature({ ...body, tenantId });
|
||||
return this.smsConfig.getClientSignatureView(signature.id, tenantId);
|
||||
}
|
||||
|
||||
@Put('signatures/:id')
|
||||
async updateSignature(@Param('id') signatureId: string, @Body() body: UpdateSmsSignatureDto, @TenantId() tenantId?: string) {
|
||||
@UsePipes(strictValidationPipe)
|
||||
async updateSignature(@Param('id') signatureId: string, @Body() body: ClientSmsSignatureUpdateDto, @CurrentTenantId() tenantId: string) {
|
||||
await this.smsConfig.updateClientSignature(signatureId, body, tenantId);
|
||||
return this.smsConfig.getClientSignatureView(signatureId, tenantId);
|
||||
}
|
||||
|
||||
@Post('signatures/:id/materials')
|
||||
createSignatureMaterial(@Param('id') signatureId: string, @Body() body: Omit<CreateSignatureMaterialDto, 'signatureId'>) {
|
||||
return this.smsConfig.createSignatureMaterial({ ...body, signatureId });
|
||||
@UsePipes(strictValidationPipe)
|
||||
createSignatureMaterial(@Param('id') signatureId: string, @Body() body: ClientSignatureMaterialDto, @CurrentTenantId() tenantId: string) {
|
||||
return this.smsConfig.createClientSignatureMaterial({ ...body, signatureId }, tenantId);
|
||||
}
|
||||
|
||||
@Get('drainage-infos')
|
||||
listDrainageInfos(@TenantId() tenantId?: string) {
|
||||
listDrainageInfos(@CurrentTenantId() tenantId: string) {
|
||||
return this.smsConfig.listClientDrainageInfos(tenantId);
|
||||
}
|
||||
|
||||
@Post('signatures/:id/drainage-infos')
|
||||
async createDrainageInfo(@Param('id') signatureId: string, @Body() body: CreateSmsDrainageInfoDto, @TenantId() tenantId?: string) {
|
||||
@UsePipes(strictValidationPipe)
|
||||
async createDrainageInfo(@Param('id') signatureId: string, @Body() body: ClientDrainageInfoDto, @CurrentTenantId() tenantId: string) {
|
||||
const item = await this.smsConfig.createDrainageInfo(signatureId, body, {}, tenantId);
|
||||
return this.smsConfig.getClientDrainageInfoView(item.id, tenantId);
|
||||
}
|
||||
|
||||
@Put('drainage-infos/:id')
|
||||
async updateDrainageInfo(@Param('id') itemId: string, @Body() body: UpdateSmsDrainageInfoDto, @TenantId() tenantId?: string) {
|
||||
@UsePipes(strictValidationPipe)
|
||||
async updateDrainageInfo(@Param('id') itemId: string, @Body() body: ClientDrainageInfoUpdateDto, @CurrentTenantId() tenantId: string) {
|
||||
await this.smsConfig.updateDrainageInfo(itemId, body, {}, tenantId);
|
||||
return this.smsConfig.getClientDrainageInfoView(itemId, tenantId);
|
||||
}
|
||||
|
||||
@Post('drainage-infos/:id/status')
|
||||
async changeDrainageInfoStatus(@Param('id') itemId: string, @Body() body: StatusChangeDto, @TenantId() tenantId?: string) {
|
||||
await this.smsConfig.changeDrainageInfoStatus(itemId, body, tenantId);
|
||||
@UsePipes(strictValidationPipe)
|
||||
async changeDrainageInfoStatus(@Param('id') itemId: string, @Body() body: ClientStatusChangeDto, @CurrentTenantId() tenantId: string, @CurrentSessionUserId() operatorId?: string) {
|
||||
await this.smsConfig.changeDrainageInfoStatus(itemId, { ...body, operatorId }, tenantId);
|
||||
if (body.status === 'deleted') return { id: itemId, status: 'deleted' };
|
||||
return this.smsConfig.getClientDrainageInfoView(itemId, tenantId);
|
||||
}
|
||||
|
||||
@Post('signatures/:id/submit')
|
||||
async submitSignature(@Param('id') signatureId: string, @TenantId() tenantId?: string) {
|
||||
async submitSignature(@Param('id') signatureId: string, @CurrentTenantId() tenantId: string) {
|
||||
await this.smsConfig.submitSignature(signatureId, tenantId);
|
||||
return this.smsConfig.getClientSignatureView(signatureId, tenantId);
|
||||
}
|
||||
|
||||
@Post('signatures/:id/status')
|
||||
async changeSignatureStatus(@Param('id') signatureId: string, @Body() body: StatusChangeDto & DeleteTargetDto, @TenantId() tenantId?: string, @CurrentSessionUserId() operatorId?: string) {
|
||||
@UsePipes(strictValidationPipe)
|
||||
async changeSignatureStatus(@Param('id') signatureId: string, @Body() body: ClientStatusChangeDto, @CurrentTenantId() tenantId: string, @CurrentSessionUserId() operatorId?: string) {
|
||||
if (body.status === 'deleted') return this.deletions.delete('signature', signatureId, { ...body, operatorId }, tenantId);
|
||||
await this.smsConfig.changeSignatureStatus(signatureId, body, tenantId);
|
||||
await this.smsConfig.changeSignatureStatus(signatureId, { ...body, operatorId }, tenantId);
|
||||
return this.smsConfig.getClientSignatureView(signatureId, tenantId);
|
||||
}
|
||||
|
||||
@Get('templates')
|
||||
listTemplates(@TenantId() tenantId?: string, @Query('includeHistory') includeHistory?: string, @Query('keyword') keyword?: string, @Query('page') page?: string, @Query('pageSize') pageSize?: string) {
|
||||
listTemplates(@CurrentTenantId() tenantId: string, @Query('includeHistory') includeHistory?: string, @Query('keyword') keyword?: string, @Query('page') page?: string, @Query('pageSize') pageSize?: string) {
|
||||
return page || pageSize
|
||||
? this.smsConfig.listTemplatesPage({ tenantId, status: includeHistory === 'true' ? 'all' : 'approved', keyword, page: Number(page), pageSize: Number(pageSize) })
|
||||
: this.smsConfig.listClientTemplates(tenantId, includeHistory === 'true');
|
||||
}
|
||||
|
||||
@Post('templates')
|
||||
createTemplate(@Body() body: CreateSmsTemplateDto, @TenantId() tenantId?: string) {
|
||||
return this.smsConfig.createTemplate({ ...body, tenantId: tenantId ?? body.tenantId });
|
||||
@UsePipes(strictValidationPipe)
|
||||
createTemplate(@Body() body: ClientSmsTemplateDto, @CurrentTenantId() tenantId: string) {
|
||||
return this.smsConfig.createTemplate({ ...body, tenantId });
|
||||
}
|
||||
|
||||
@Put('templates/:id')
|
||||
updateTemplate(@Param('id') templateId: string, @Body() body: UpdateSmsTemplateDto, @TenantId() tenantId?: string) {
|
||||
@UsePipes(strictValidationPipe)
|
||||
updateTemplate(@Param('id') templateId: string, @Body() body: ClientSmsTemplateUpdateDto, @CurrentTenantId() tenantId: string) {
|
||||
return this.smsConfig.updateClientTemplate(templateId, body, tenantId);
|
||||
}
|
||||
|
||||
@Post('templates/:id/submit')
|
||||
submitTemplate(@Param('id') templateId: string, @TenantId() tenantId?: string) {
|
||||
submitTemplate(@Param('id') templateId: string, @CurrentTenantId() tenantId: string) {
|
||||
return this.smsConfig.submitTemplate(templateId, tenantId);
|
||||
}
|
||||
|
||||
@Post('templates/:id/status')
|
||||
changeTemplateStatus(@Param('id') templateId: string, @Body() body: StatusChangeDto & DeleteTargetDto, @TenantId() tenantId?: string, @CurrentSessionUserId() operatorId?: string) {
|
||||
@UsePipes(strictValidationPipe)
|
||||
changeTemplateStatus(@Param('id') templateId: string, @Body() body: ClientStatusChangeDto, @CurrentTenantId() tenantId: string, @CurrentSessionUserId() operatorId?: string) {
|
||||
if (body.status === 'deleted') return this.deletions.delete('template', templateId, { ...body, operatorId }, tenantId);
|
||||
return this.smsConfig.changeTemplateStatus(templateId, body, tenantId);
|
||||
return this.smsConfig.changeTemplateStatus(templateId, { ...body, operatorId }, tenantId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,10 +81,20 @@ export class SmsConfigService implements OnModuleInit, OnModuleDestroy {
|
||||
return this.applications.resetApplicationSecret(applicationId, data);
|
||||
}
|
||||
|
||||
async resetClientApplicationSecret(applicationId: string, data: StatusChangeDto, tenantId: string) {
|
||||
await this.applications.getApplication(applicationId, tenantId);
|
||||
return this.applications.resetApplicationSecret(applicationId, data);
|
||||
}
|
||||
|
||||
async changeApplicationStatus(applicationId: string, data: StatusChangeDto) {
|
||||
return this.lifecycle.changeApplicationStatus(applicationId, data);
|
||||
}
|
||||
|
||||
async changeClientApplicationStatus(applicationId: string, data: StatusChangeDto, tenantId: string) {
|
||||
await this.applications.getApplication(applicationId, tenantId);
|
||||
return this.lifecycle.changeApplicationStatus(applicationId, data);
|
||||
}
|
||||
|
||||
async getApplicationDeactivationPreview(applicationId: string) {
|
||||
return this.lifecycle.getApplicationDeactivationPreview(applicationId);
|
||||
}
|
||||
@@ -177,6 +187,11 @@ export class SmsConfigService implements OnModuleInit, OnModuleDestroy {
|
||||
return this.signatures.createSignatureMaterial(data);
|
||||
}
|
||||
|
||||
async createClientSignatureMaterial(data: CreateSignatureMaterialDto, tenantId: string) {
|
||||
await this.signatures.getClientSignatureView(data.signatureId, tenantId);
|
||||
return this.signatures.createSignatureMaterial(data);
|
||||
}
|
||||
|
||||
async submitSignature(signatureId: string, tenantId?: string) {
|
||||
return this.signatures.submitSignature(signatureId, tenantId);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query } from '@nestjs/common';
|
||||
import { ApiOkResponse, ApiTags } from '@nestjs/swagger';
|
||||
import { TenantId } from '../common/tenant-id.decorator';
|
||||
import { CurrentTenantId } from '../auth/current-tenant-id.decorator';
|
||||
import { RequireRecentAuthentication } from '../auth/require-recent-authentication.decorator';
|
||||
import { CurrentSessionUserId } from '../auth/current-session-user.decorator';
|
||||
import { AdminUserResponseDto, ClientUserResponseDto } from './user-response.dto';
|
||||
@@ -72,7 +72,7 @@ export class UsersController {
|
||||
@Get('client/users')
|
||||
@ApiOkResponse({ type: [ClientUserResponseDto] })
|
||||
listClient(
|
||||
@TenantId() tenantId?: string,
|
||||
@CurrentTenantId() tenantId: string,
|
||||
@Query('displayName') displayName?: string,
|
||||
@Query('login') login?: string,
|
||||
@Query('status') status?: string,
|
||||
@@ -82,31 +82,31 @@ export class UsersController {
|
||||
|
||||
@Post('client/users')
|
||||
@RequireRecentAuthentication()
|
||||
createClient(@TenantId() tenantId: string | undefined, @Body() body: CreateUserDto, @CurrentSessionUserId() operatorId?: string) {
|
||||
createClient(@CurrentTenantId() tenantId: string, @Body() body: CreateUserDto, @CurrentSessionUserId() operatorId?: string) {
|
||||
return this.users.create({ ...body, roleCode: 'enterprise_admin', operatorId }, tenantId);
|
||||
}
|
||||
|
||||
@Put('client/users/:id')
|
||||
@RequireRecentAuthentication()
|
||||
updateClient(@TenantId() tenantId: string | undefined, @Param('id') id: string, @Body() body: UpdateUserDto, @CurrentSessionUserId() operatorId?: string) {
|
||||
updateClient(@CurrentTenantId() tenantId: string, @Param('id') id: string, @Body() body: UpdateUserDto, @CurrentSessionUserId() operatorId?: string) {
|
||||
return this.users.update(id, { ...body, roleCode: 'enterprise_admin', operatorId }, tenantId);
|
||||
}
|
||||
|
||||
@Post('client/users/:id/status')
|
||||
@RequireRecentAuthentication()
|
||||
changeClientStatus(@TenantId() tenantId: string | undefined, @Param('id') id: string, @Body() body: ChangeUserStatusDto, @CurrentSessionUserId() operatorId?: string) {
|
||||
changeClientStatus(@CurrentTenantId() tenantId: string, @Param('id') id: string, @Body() body: ChangeUserStatusDto, @CurrentSessionUserId() operatorId?: string) {
|
||||
return this.users.changeStatus(id, { ...body, operatorId }, tenantId, operatorId);
|
||||
}
|
||||
|
||||
@Post('client/users/:id/password')
|
||||
@RequireRecentAuthentication()
|
||||
changeClientPassword(@TenantId() tenantId: string | undefined, @Param('id') id: string, @Body() body: ChangePasswordDto, @CurrentSessionUserId() operatorId?: string) {
|
||||
changeClientPassword(@CurrentTenantId() tenantId: string, @Param('id') id: string, @Body() body: ChangePasswordDto, @CurrentSessionUserId() operatorId?: string) {
|
||||
return this.users.changePassword(id, { ...body, operatorId }, tenantId);
|
||||
}
|
||||
|
||||
@Delete('client/users/:id')
|
||||
@RequireRecentAuthentication()
|
||||
removeClient(@TenantId() tenantId: string | undefined, @Param('id') id: string, @CurrentSessionUserId() operatorId?: string) {
|
||||
removeClient(@CurrentTenantId() tenantId: string, @Param('id') id: string, @CurrentSessionUserId() operatorId?: string) {
|
||||
return this.users.remove(id, operatorId, tenantId);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { BadRequestException, ForbiddenException } from '@nestjs/common';
|
||||
import { hashPassword, UsersService } from './users.service';
|
||||
import { legacyHashPassword, verifyPassword } from '../auth/password-hasher';
|
||||
import { UsersService } from './users.service';
|
||||
|
||||
function createPrismaMock() {
|
||||
const roles = new Map<string, { id: string; code: string; name: string; scope: string }>();
|
||||
@@ -10,6 +11,7 @@ function createPrismaMock() {
|
||||
findFirst: jest.fn(),
|
||||
findUnique: jest.fn(),
|
||||
update: jest.fn(),
|
||||
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
|
||||
count: jest.fn().mockResolvedValue(2),
|
||||
},
|
||||
role: {
|
||||
@@ -206,7 +208,7 @@ describe('UsersService', () => {
|
||||
it('returns a safe view after changing the current password', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
prisma.user.findFirst.mockResolvedValue({
|
||||
id: 'user-1', tenantId: 'tenant-1', username: 'admin', passwordHash: hashPassword('old-password'),
|
||||
id: 'user-1', tenantId: 'tenant-1', username: 'admin', passwordHash: legacyHashPassword('old-password'),
|
||||
sessionVersion: 3, failedLoginCount: 0, roles: [{ role: { code: 'enterprise_admin' } }],
|
||||
});
|
||||
prisma.user.update.mockResolvedValue({
|
||||
@@ -223,6 +225,30 @@ describe('UsersService', () => {
|
||||
expect(user).not.toHaveProperty('failedLoginCount');
|
||||
});
|
||||
|
||||
it('transparently upgrades a legacy password hash after successful login verification', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
const service = new UsersService(prisma as never);
|
||||
const legacyHash = legacyHashPassword('correct-password');
|
||||
|
||||
await expect(service.verifyLoginPassword('user-1', 'correct-password', legacyHash)).resolves.toBe(true);
|
||||
|
||||
expect(prisma.user.updateMany).toHaveBeenCalledWith({
|
||||
where: { id: 'user-1', passwordHash: legacyHash },
|
||||
data: { passwordHash: expect.stringMatching(/^\$scrypt\$/) },
|
||||
});
|
||||
const upgradedHash = prisma.user.updateMany.mock.calls[0][0].data.passwordHash;
|
||||
await expect(verifyPassword('correct-password', upgradedHash)).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('does not rewrite a legacy password hash after failed login verification', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
const service = new UsersService(prisma as never);
|
||||
|
||||
await expect(service.verifyLoginPassword('user-1', 'wrong-password', legacyHashPassword('correct-password'))).resolves.toBe(false);
|
||||
|
||||
expect(prisma.user.updateMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('forbids deleting the current signed-in user', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
prisma.user.findFirst.mockResolvedValue({
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { BadRequestException, ConflictException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { hashPassword, passwordNeedsRehash, verifyPassword } from '../auth/password-hasher';
|
||||
|
||||
export type UserRoleCode = 'platform_admin' | 'enterprise_admin';
|
||||
|
||||
@@ -131,6 +131,7 @@ export class UsersService {
|
||||
this.assertUserInput({ ...data, roleCode }, scopeTenantId, true);
|
||||
const tenantId = scopeTenantId ?? data.tenantId;
|
||||
const role = await this.ensureRole(roleCode);
|
||||
const passwordHash = await hashPassword(data.password);
|
||||
const user = await this.mapUniqueConflict(() => this.prisma.user.create({
|
||||
data: {
|
||||
tenantId,
|
||||
@@ -138,7 +139,7 @@ export class UsersService {
|
||||
email: normalizeOptional(data.email),
|
||||
phone: normalizeOptional(data.phone),
|
||||
displayName: data.displayName,
|
||||
passwordHash: hashPassword(data.password),
|
||||
passwordHash,
|
||||
status: data.status ?? 'active',
|
||||
roles: { create: [{ roleId: role.id }] },
|
||||
},
|
||||
@@ -208,9 +209,10 @@ export class UsersService {
|
||||
throw new BadRequestException('password must be at least 6 characters');
|
||||
}
|
||||
const current = await this.getExisting(id, scopeTenantId);
|
||||
const passwordHash = await hashPassword(data.password);
|
||||
const updated = await this.prisma.user.update({
|
||||
where: { id },
|
||||
data: { passwordHash: hashPassword(data.password), failedLoginCount: 0, lockedUntil: null, sessionVersion: { increment: 1 } },
|
||||
data: { passwordHash, failedLoginCount: 0, lockedUntil: null, sessionVersion: { increment: 1 } },
|
||||
include: { tenant: true, roles: { include: { role: true } } },
|
||||
});
|
||||
await this.writeLog(current.tenantId, data.operatorId, 'user.password_changed', id, { username: current.username });
|
||||
@@ -259,12 +261,13 @@ export class UsersService {
|
||||
throw new BadRequestException('currentPassword and a password of at least 6 characters are required');
|
||||
}
|
||||
const current = await this.getExisting(id);
|
||||
if (current.passwordHash !== hashPassword(currentPassword)) {
|
||||
if (!await verifyPassword(currentPassword, current.passwordHash)) {
|
||||
throw new BadRequestException('当前密码不正确');
|
||||
}
|
||||
const passwordHash = await hashPassword(password);
|
||||
const updated = await this.prisma.user.update({
|
||||
where: { id },
|
||||
data: { passwordHash: hashPassword(password), failedLoginCount: 0, lockedUntil: null, sessionVersion: { increment: 1 } },
|
||||
data: { passwordHash, failedLoginCount: 0, lockedUntil: null, sessionVersion: { increment: 1 } },
|
||||
include: { tenant: true, roles: { include: { role: true } } },
|
||||
});
|
||||
await this.writeLog(current.tenantId, id, 'user.password_changed_self', id, { username: current.username });
|
||||
@@ -276,12 +279,25 @@ export class UsersService {
|
||||
throw new BadRequestException('请输入当前密码');
|
||||
}
|
||||
const current = await this.getExisting(id);
|
||||
if (current.passwordHash !== hashPassword(password)) {
|
||||
if (!await verifyPassword(password, current.passwordHash)) {
|
||||
throw new BadRequestException('当前密码不正确');
|
||||
}
|
||||
if (passwordNeedsRehash(current.passwordHash)) {
|
||||
const passwordHash = await hashPassword(password);
|
||||
await this.prisma.user.updateMany({ where: { id, passwordHash: current.passwordHash }, data: { passwordHash } });
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
async verifyLoginPassword(id: string, password: string, storedHash: string) {
|
||||
if (!await verifyPassword(password, storedHash)) return false;
|
||||
if (passwordNeedsRehash(storedHash)) {
|
||||
const passwordHash = await hashPassword(password);
|
||||
await this.prisma.user.updateMany({ where: { id, passwordHash: storedHash }, data: { passwordHash } });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
listRoles() {
|
||||
return this.prisma.role.findMany({
|
||||
include: { permissions: { include: { permission: true } } },
|
||||
@@ -440,7 +456,3 @@ function normalizeOptional(value?: string | null) {
|
||||
const next = value?.trim();
|
||||
return next ? next : null;
|
||||
}
|
||||
|
||||
export function hashPassword(password: string) {
|
||||
return createHash('sha256').update(password).digest('hex');
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user