refactor(core): Port 3 more controllers to use DTOs (no-changelog) (#12375)

This commit is contained in:
कारतोफ्फेलस्क्रिप्ट™
2024-12-26 16:09:42 +01:00
committed by GitHub
parent 1d5e891a0d
commit 371a09de96
36 changed files with 813 additions and 240 deletions

View File

@@ -0,0 +1,93 @@
import { LoginRequestDto } from '../login-request.dto';
describe('LoginRequestDto', () => {
describe('Valid requests', () => {
test.each([
{
name: 'complete valid login request',
request: {
email: 'test@example.com',
password: 'securePassword123',
mfaCode: '123456',
},
},
{
name: 'login request without optional MFA',
request: {
email: 'test@example.com',
password: 'securePassword123',
},
},
{
name: 'login request with both mfaCode and mfaRecoveryCode',
request: {
email: 'test@example.com',
password: 'securePassword123',
mfaCode: '123456',
mfaRecoveryCode: 'recovery-code-123',
},
},
{
name: 'login request with only mfaRecoveryCode',
request: {
email: 'test@example.com',
password: 'securePassword123',
mfaRecoveryCode: 'recovery-code-123',
},
},
])('should validate $name', ({ request }) => {
const result = LoginRequestDto.safeParse(request);
expect(result.success).toBe(true);
});
});
describe('Invalid requests', () => {
test.each([
{
name: 'invalid email',
request: {
email: 'invalid-email',
password: 'securePassword123',
},
expectedErrorPath: ['email'],
},
{
name: 'empty password',
request: {
email: 'test@example.com',
password: '',
},
expectedErrorPath: ['password'],
},
{
name: 'missing email',
request: {
password: 'securePassword123',
},
expectedErrorPath: ['email'],
},
{
name: 'missing password',
request: {
email: 'test@example.com',
},
expectedErrorPath: ['password'],
},
{
name: 'whitespace in email and password',
request: {
email: ' test@example.com ',
password: ' securePassword123 ',
},
expectedErrorPath: ['email'],
},
])('should fail validation for $name', ({ request, expectedErrorPath }) => {
const result = LoginRequestDto.safeParse(request);
expect(result.success).toBe(false);
if (expectedErrorPath) {
expect(result.error?.issues[0].path).toEqual(expectedErrorPath);
}
});
});
});

View File

@@ -0,0 +1,87 @@
import { ResolveSignupTokenQueryDto } from '../resolve-signup-token-query.dto';
describe('ResolveSignupTokenQueryDto', () => {
const validUuid = '123e4567-e89b-12d3-a456-426614174000';
describe('Valid requests', () => {
test.each([
{
name: 'standard UUID',
request: {
inviterId: validUuid,
inviteeId: validUuid,
},
},
])('should validate $name', ({ request }) => {
const result = ResolveSignupTokenQueryDto.safeParse(request);
expect(result.success).toBe(true);
});
});
describe('Invalid requests', () => {
test.each([
{
name: 'invalid inviterId UUID',
request: {
inviterId: 'not-a-valid-uuid',
inviteeId: validUuid,
},
expectedErrorPath: ['inviterId'],
},
{
name: 'invalid inviteeId UUID',
request: {
inviterId: validUuid,
inviteeId: 'not-a-valid-uuid',
},
expectedErrorPath: ['inviteeId'],
},
{
name: 'missing inviterId',
request: {
inviteeId: validUuid,
},
expectedErrorPath: ['inviterId'],
},
{
name: 'missing inviteeId',
request: {
inviterId: validUuid,
},
expectedErrorPath: ['inviteeId'],
},
{
name: 'UUID with invalid characters',
request: {
inviterId: '123e4567-e89b-12d3-a456-42661417400G',
inviteeId: validUuid,
},
expectedErrorPath: ['inviterId'],
},
{
name: 'UUID too long',
request: {
inviterId: '123e4567-e89b-12d3-a456-426614174001234',
inviteeId: validUuid,
},
expectedErrorPath: ['inviterId'],
},
{
name: 'UUID too short',
request: {
inviterId: '123e4567-e89b-12d3-a456',
inviteeId: validUuid,
},
expectedErrorPath: ['inviterId'],
},
])('should fail validation for $name', ({ request, expectedErrorPath }) => {
const result = ResolveSignupTokenQueryDto.safeParse(request);
expect(result.success).toBe(false);
if (expectedErrorPath) {
expect(result.error?.issues[0].path).toEqual(expectedErrorPath);
}
});
});
});

View File

@@ -0,0 +1,9 @@
import { z } from 'zod';
import { Z } from 'zod-class';
export class LoginRequestDto extends Z.class({
email: z.string().email(),
password: z.string().min(1),
mfaCode: z.string().optional(),
mfaRecoveryCode: z.string().optional(),
}) {}

View File

@@ -0,0 +1,7 @@
import { z } from 'zod';
import { Z } from 'zod-class';
export class ResolveSignupTokenQueryDto extends Z.class({
inviterId: z.string().uuid(),
inviteeId: z.string().uuid(),
}) {}

View File

@@ -1,9 +1,22 @@
export { AiAskRequestDto } from './ai/ai-ask-request.dto';
export { AiChatRequestDto } from './ai/ai-chat-request.dto';
export { AiApplySuggestionRequestDto } from './ai/ai-apply-suggestion-request.dto';
export { LoginRequestDto } from './auth/login-request.dto';
export { ResolveSignupTokenQueryDto } from './auth/resolve-signup-token-query.dto';
export { OwnerSetupRequestDto } from './owner/owner-setup-request.dto';
export { DismissBannerRequestDto } from './owner/dismiss-banner-request.dto';
export { ForgotPasswordRequestDto } from './password-reset/forgot-password-request.dto';
export { ResolvePasswordTokenQueryDto } from './password-reset/resolve-password-token-query.dto';
export { ChangePasswordRequestDto } from './password-reset/change-password-request.dto';
export { PasswordUpdateRequestDto } from './user/password-update-request.dto';
export { RoleChangeRequestDto } from './user/role-change-request.dto';
export { SettingsUpdateRequestDto } from './user/settings-update-request.dto';
export { UserUpdateRequestDto } from './user/user-update-request.dto';
export { CommunityRegisteredRequestDto } from './license/community-registered-request.dto';
export { VariableListRequestDto } from './variables/variables-list-request.dto';

View File

@@ -0,0 +1,64 @@
import { bannerNameSchema } from '../../../schemas/bannerName.schema';
import { DismissBannerRequestDto } from '../dismiss-banner-request.dto';
describe('DismissBannerRequestDto', () => {
describe('Valid requests', () => {
test.each(
bannerNameSchema.options.map((banner) => ({
name: `valid banner: ${banner}`,
request: { banner },
})),
)('should validate $name', ({ request }) => {
const result = DismissBannerRequestDto.safeParse(request);
expect(result.success).toBe(true);
});
});
describe('Invalid requests', () => {
test.each([
{
name: 'invalid banner string',
request: {
banner: 'not-a-valid-banner',
},
expectedErrorPath: ['banner'],
},
{
name: 'non-string banner',
request: {
banner: 123,
},
expectedErrorPath: ['banner'],
},
])('should fail validation for $name', ({ request, expectedErrorPath }) => {
const result = DismissBannerRequestDto.safeParse(request);
expect(result.success).toBe(false);
if (expectedErrorPath) {
expect(result.error?.issues[0].path).toEqual(expectedErrorPath);
}
});
});
describe('Optional banner', () => {
test('should validate empty request', () => {
const result = DismissBannerRequestDto.safeParse({});
expect(result.success).toBe(true);
});
});
describe('Exhaustive banner name check', () => {
test('should have all banner names defined', () => {
const expectedBanners = [
'V1',
'TRIAL_OVER',
'TRIAL',
'NON_PRODUCTION_LICENSE',
'EMAIL_CONFIRMATION',
];
expect(bannerNameSchema.options).toEqual(expectedBanners);
});
});
});

View File

@@ -0,0 +1,93 @@
import { OwnerSetupRequestDto } from '../owner-setup-request.dto';
describe('OwnerSetupRequestDto', () => {
describe('Valid requests', () => {
test.each([
{
name: 'complete valid setup request',
request: {
email: 'owner@example.com',
firstName: 'John',
lastName: 'Doe',
password: 'SecurePassword123',
},
},
])('should validate $name', ({ request }) => {
const result = OwnerSetupRequestDto.safeParse(request);
expect(result.success).toBe(true);
});
});
describe('Invalid requests', () => {
test.each([
{
name: 'invalid email',
request: {
email: 'invalid-email',
firstName: 'John',
lastName: 'Doe',
password: 'SecurePassword123',
},
expectedErrorPath: ['email'],
},
{
name: 'missing first name',
request: {
email: 'owner@example.com',
firstName: '',
lastName: 'Doe',
password: 'SecurePassword123',
},
expectedErrorPath: ['firstName'],
},
{
name: 'missing last name',
request: {
email: 'owner@example.com',
firstName: 'John',
lastName: '',
password: 'SecurePassword123',
},
expectedErrorPath: ['lastName'],
},
{
name: 'password too short',
request: {
email: 'owner@example.com',
firstName: 'John',
lastName: 'Doe',
password: 'short',
},
expectedErrorPath: ['password'],
},
{
name: 'password without number',
request: {
email: 'owner@example.com',
firstName: 'John',
lastName: 'Doe',
password: 'NoNumberPassword',
},
expectedErrorPath: ['password'],
},
{
name: 'password without uppercase letter',
request: {
email: 'owner@example.com',
firstName: 'John',
lastName: 'Doe',
password: 'nouppercasepassword123',
},
expectedErrorPath: ['password'],
},
])('should fail validation for $name', ({ request, expectedErrorPath }) => {
const result = OwnerSetupRequestDto.safeParse(request);
expect(result.success).toBe(false);
if (expectedErrorPath) {
expect(result.error?.issues[0].path).toEqual(expectedErrorPath);
}
});
});
});

View File

@@ -0,0 +1,7 @@
import { Z } from 'zod-class';
import { bannerNameSchema } from '../../schemas/bannerName.schema';
export class DismissBannerRequestDto extends Z.class({
banner: bannerNameSchema.optional(),
}) {}

View File

@@ -0,0 +1,11 @@
import { z } from 'zod';
import { Z } from 'zod-class';
import { passwordSchema } from '../../schemas/password.schema';
export class OwnerSetupRequestDto extends Z.class({
email: z.string().email(),
firstName: z.string().min(1, 'First name is required'),
lastName: z.string().min(1, 'Last name is required'),
password: passwordSchema,
}) {}

View File

@@ -0,0 +1,114 @@
import { ChangePasswordRequestDto } from '../change-password-request.dto';
describe('ChangePasswordRequestDto', () => {
describe('Valid requests', () => {
test.each([
{
name: 'valid password reset with token',
request: {
token: 'valid-reset-token-with-sufficient-length',
password: 'newSecurePassword123',
},
},
{
name: 'valid password reset with MFA code',
request: {
token: 'another-valid-reset-token',
password: 'newSecurePassword123',
mfaCode: '123456',
},
},
])('should validate $name', ({ request }) => {
const result = ChangePasswordRequestDto.safeParse(request);
expect(result.success).toBe(true);
});
});
describe('Invalid requests', () => {
test.each([
{
name: 'missing token',
request: { password: 'newSecurePassword123' },
expectedErrorPath: ['token'],
},
{
name: 'empty token',
request: { token: '', password: 'newSecurePassword123' },
expectedErrorPath: ['token'],
},
{
name: 'short token',
request: { token: 'short', password: 'newSecurePassword123' },
expectedErrorPath: ['token'],
},
{
name: 'missing password',
request: { token: 'valid-reset-token' },
expectedErrorPath: ['password'],
},
{
name: 'password too short',
request: {
token: 'valid-reset-token',
password: 'short',
},
expectedErrorPath: ['password'],
},
{
name: 'password too long',
request: {
token: 'valid-reset-token',
password: 'a'.repeat(65),
},
expectedErrorPath: ['password'],
},
{
name: 'password without number',
request: {
token: 'valid-reset-token',
password: 'NoNumberPassword',
},
expectedErrorPath: ['password'],
},
{
name: 'password without uppercase letter',
request: {
token: 'valid-reset-token',
password: 'nouppercasepassword123',
},
expectedErrorPath: ['password'],
},
])('should fail validation for $name', ({ request, expectedErrorPath }) => {
const result = ChangePasswordRequestDto.safeParse(request);
expect(result.success).toBe(false);
if (expectedErrorPath) {
expect(result.error?.issues[0].path).toEqual(expectedErrorPath);
}
});
describe('Edge cases', () => {
test('should handle optional MFA code correctly', () => {
const validRequest = {
token: 'valid-reset-token',
password: 'newSecurePassword123',
mfaCode: undefined,
};
const result = ChangePasswordRequestDto.safeParse(validRequest);
expect(result.success).toBe(true);
});
test('should handle token with special characters', () => {
const validRequest = {
token: 'valid-reset-token-with-special-!@#$%^&*()_+',
password: 'newSecurePassword123',
};
const result = ChangePasswordRequestDto.safeParse(validRequest);
expect(result.success).toBe(true);
});
});
});
});

View File

@@ -0,0 +1,47 @@
import { ForgotPasswordRequestDto } from '../forgot-password-request.dto';
describe('ForgotPasswordRequestDto', () => {
describe('Valid requests', () => {
test.each([
{
name: 'valid email',
request: { email: 'test@example.com' },
},
{
name: 'email with subdomain',
request: { email: 'user@sub.example.com' },
},
])('should validate $name', ({ request }) => {
const result = ForgotPasswordRequestDto.safeParse(request);
expect(result.success).toBe(true);
});
});
describe('Invalid requests', () => {
test.each([
{
name: 'invalid email format',
request: { email: 'invalid-email' },
expectedErrorPath: ['email'],
},
{
name: 'missing email',
request: {},
expectedErrorPath: ['email'],
},
{
name: 'empty email',
request: { email: '' },
expectedErrorPath: ['email'],
},
])('should fail validation for $name', ({ request, expectedErrorPath }) => {
const result = ForgotPasswordRequestDto.safeParse(request);
expect(result.success).toBe(false);
if (expectedErrorPath) {
expect(result.error?.issues[0].path).toEqual(expectedErrorPath);
}
});
});
});

View File

@@ -0,0 +1,42 @@
import { ResolvePasswordTokenQueryDto } from '../resolve-password-token-query.dto';
describe('ResolvePasswordTokenQueryDto', () => {
describe('Valid requests', () => {
test.each([
{
name: 'valid token',
request: { token: 'valid-reset-token' },
},
{
name: 'long token',
request: { token: 'x'.repeat(50) },
},
])('should validate $name', ({ request }) => {
const result = ResolvePasswordTokenQueryDto.safeParse(request);
expect(result.success).toBe(true);
});
});
describe('Invalid requests', () => {
test.each([
{
name: 'missing token',
request: {},
expectedErrorPath: ['token'],
},
{
name: 'empty token',
request: { token: '' },
expectedErrorPath: ['token'],
},
])('should fail validation for $name', ({ request, expectedErrorPath }) => {
const result = ResolvePasswordTokenQueryDto.safeParse(request);
expect(result.success).toBe(false);
if (expectedErrorPath) {
expect(result.error?.issues[0].path).toEqual(expectedErrorPath);
}
});
});
});

View File

@@ -0,0 +1,11 @@
import { z } from 'zod';
import { Z } from 'zod-class';
import { passwordSchema } from '../../schemas/password.schema';
import { passwordResetTokenSchema } from '../../schemas/passwordResetToken.schema';
export class ChangePasswordRequestDto extends Z.class({
token: passwordResetTokenSchema,
password: passwordSchema,
mfaCode: z.string().optional(),
}) {}

View File

@@ -0,0 +1,6 @@
import { z } from 'zod';
import { Z } from 'zod-class';
export class ForgotPasswordRequestDto extends Z.class({
email: z.string().email(),
}) {}

View File

@@ -0,0 +1,7 @@
import { Z } from 'zod-class';
import { passwordResetTokenSchema } from '../../schemas/passwordResetToken.schema';
export class ResolvePasswordTokenQueryDto extends Z.class({
token: passwordResetTokenSchema,
}) {}

View File

@@ -5,5 +5,6 @@ export type * from './scaling';
export type * from './frontend-settings';
export type * from './user';
export type { BannerName } from './schemas/bannerName.schema';
export type { Collaborator } from './push/collaboration';
export type { SendWorkerStatusMessage } from './push/worker';

View File

@@ -0,0 +1,11 @@
import { z } from 'zod';
export const bannerNameSchema = z.enum([
'V1',
'TRIAL_OVER',
'TRIAL',
'NON_PRODUCTION_LICENSE',
'EMAIL_CONFIRMATION',
]);
export type BannerName = z.infer<typeof bannerNameSchema>;

View File

@@ -0,0 +1,16 @@
import { z } from 'zod';
// TODO: Delete these from `cli` after all password-validation code starts using this schema
const minLength = 8;
const maxLength = 64;
export const passwordSchema = z
.string()
.min(minLength, `Password must be ${minLength} to ${maxLength} characters long.`)
.max(maxLength, `Password must be ${minLength} to ${maxLength} characters long.`)
.refine((password) => /\d/.test(password), {
message: 'Password must contain at least 1 number.',
})
.refine((password) => /[A-Z]/.test(password), {
message: 'Password must contain at least 1 uppercase letter.',
});

View File

@@ -0,0 +1,3 @@
import { z } from 'zod';
export const passwordResetTokenSchema = z.string().min(10, 'Token too short');

View File

@@ -1,7 +1,7 @@
import type { DismissBannerRequestDto, OwnerSetupRequestDto } from '@n8n/api-types';
import type { Response } from 'express';
import { anyObject, mock } from 'jest-mock-extended';
import jwt from 'jsonwebtoken';
import Container from 'typedi';
import { mock } from 'jest-mock-extended';
import type { Logger } from 'n8n-core';
import type { AuthService } from '@/auth/auth.service';
import config from '@/config';
@@ -10,27 +10,31 @@ import type { User } from '@/databases/entities/user';
import type { SettingsRepository } from '@/databases/repositories/settings.repository';
import type { UserRepository } from '@/databases/repositories/user.repository';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { License } from '@/license';
import type { OwnerRequest } from '@/requests';
import { PasswordUtility } from '@/services/password.utility';
import type { EventService } from '@/events/event.service';
import type { PublicUser } from '@/interfaces';
import type { AuthenticatedRequest } from '@/requests';
import type { PasswordUtility } from '@/services/password.utility';
import type { UserService } from '@/services/user.service';
import { mockInstance } from '@test/mocking';
import { badPasswords } from '@test/test-data';
describe('OwnerController', () => {
const configGetSpy = jest.spyOn(config, 'getEnv');
const configSetSpy = jest.spyOn(config, 'set');
const logger = mock<Logger>();
const eventService = mock<EventService>();
const authService = mock<AuthService>();
const userService = mock<UserService>();
const userRepository = mock<UserRepository>();
const settingsRepository = mock<SettingsRepository>();
mockInstance(License).isWithinUsersLimit.mockReturnValue(true);
const passwordUtility = mock<PasswordUtility>();
const controller = new OwnerController(
mock(),
mock(),
logger,
eventService,
settingsRepository,
authService,
userService,
Container.get(PasswordUtility),
passwordUtility,
mock(),
userRepository,
);
@@ -38,38 +42,18 @@ describe('OwnerController', () => {
describe('setupOwner', () => {
it('should throw a BadRequestError if the instance owner is already setup', async () => {
configGetSpy.mockReturnValue(true);
await expect(controller.setupOwner(mock(), mock())).rejects.toThrowError(
await expect(controller.setupOwner(mock(), mock(), mock())).rejects.toThrowError(
new BadRequestError('Instance owner already setup'),
);
});
it('should throw a BadRequestError if the email is invalid', async () => {
configGetSpy.mockReturnValue(false);
const req = mock<OwnerRequest.Post>({ body: { email: 'invalid email' } });
await expect(controller.setupOwner(req, mock())).rejects.toThrowError(
new BadRequestError('Invalid email address'),
);
});
describe('should throw if the password is invalid', () => {
Object.entries(badPasswords).forEach(([password, errorMessage]) => {
it(password, async () => {
configGetSpy.mockReturnValue(false);
const req = mock<OwnerRequest.Post>({ body: { email: 'valid@email.com', password } });
await expect(controller.setupOwner(req, mock())).rejects.toThrowError(
new BadRequestError(errorMessage),
);
});
});
});
it('should throw a BadRequestError if firstName & lastName are missing ', async () => {
configGetSpy.mockReturnValue(false);
const req = mock<OwnerRequest.Post>({
body: { email: 'valid@email.com', password: 'NewPassword123', firstName: '', lastName: '' },
});
await expect(controller.setupOwner(req, mock())).rejects.toThrowError(
new BadRequestError('First and last names are mandatory'),
expect(userRepository.findOneOrFail).not.toHaveBeenCalled();
expect(userRepository.save).not.toHaveBeenCalled();
expect(authService.issueCookie).not.toHaveBeenCalled();
expect(settingsRepository.update).not.toHaveBeenCalled();
expect(configSetSpy).not.toHaveBeenCalled();
expect(eventService.emit).not.toHaveBeenCalled();
expect(logger.debug).toHaveBeenCalledWith(
'Request to claim instance ownership failed because instance owner already exists',
);
});
@@ -80,29 +64,52 @@ describe('OwnerController', () => {
authIdentities: [],
});
const browserId = 'test-browser-id';
const req = mock<OwnerRequest.Post>({
body: {
email: 'valid@email.com',
password: 'NewPassword123',
firstName: 'Jane',
lastName: 'Doe',
},
user,
browserId,
});
const req = mock<AuthenticatedRequest>({ user, browserId });
const res = mock<Response>();
const payload = mock<OwnerSetupRequestDto>({
email: 'valid@email.com',
password: 'NewPassword123',
firstName: 'Jane',
lastName: 'Doe',
});
configGetSpy.mockReturnValue(false);
userRepository.findOneOrFail.calledWith(anyObject()).mockResolvedValue(user);
userRepository.save.calledWith(anyObject()).mockResolvedValue(user);
jest.spyOn(jwt, 'sign').mockImplementation(() => 'signed-token');
userRepository.findOneOrFail.mockResolvedValue(user);
userRepository.save.mockResolvedValue(user);
userService.toPublic.mockResolvedValue(mock<PublicUser>({ id: 'newUserId' }));
await controller.setupOwner(req, res);
const result = await controller.setupOwner(req, res, payload);
expect(userRepository.findOneOrFail).toHaveBeenCalledWith({
where: { role: 'global:owner' },
});
expect(userRepository.save).toHaveBeenCalledWith(user, { transaction: false });
expect(authService.issueCookie).toHaveBeenCalledWith(res, user, browserId);
expect(settingsRepository.update).toHaveBeenCalledWith(
{ key: 'userManagement.isInstanceOwnerSetUp' },
{ value: JSON.stringify(true) },
);
expect(configSetSpy).toHaveBeenCalledWith('userManagement.isInstanceOwnerSetUp', true);
expect(eventService.emit).toHaveBeenCalledWith('instance-owner-setup', { userId: 'userId' });
expect(result.id).toEqual('newUserId');
});
});
describe('dismissBanner', () => {
it('should not call dismissBanner if no banner is provided', async () => {
const payload = mock<DismissBannerRequestDto>({ banner: undefined });
const result = await controller.dismissBanner(mock(), mock(), payload);
expect(settingsRepository.dismissBanner).not.toHaveBeenCalled();
expect(result).toBeUndefined();
});
it('should call dismissBanner with the correct banner name', async () => {
const payload = mock<DismissBannerRequestDto>({ banner: 'TRIAL' });
await controller.dismissBanner(mock(), mock(), payload);
expect(settingsRepository.dismissBanner).toHaveBeenCalledWith({ bannerName: 'TRIAL' });
});
});
});

View File

@@ -1,14 +1,13 @@
import { LoginRequestDto, ResolveSignupTokenQueryDto } from '@n8n/api-types';
import { Response } from 'express';
import { Logger } from 'n8n-core';
import { ApplicationError } from 'n8n-workflow';
import validator from 'validator';
import { handleEmailLogin, handleLdapLogin } from '@/auth';
import { AuthService } from '@/auth/auth.service';
import { RESPONSE_ERROR_MESSAGES } from '@/constants';
import type { User } from '@/databases/entities/user';
import { UserRepository } from '@/databases/repositories/user.repository';
import { Get, Post, RestController } from '@/decorators';
import { Body, Get, Post, Query, RestController } from '@/decorators';
import { AuthError } from '@/errors/response-errors/auth.error';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { ForbiddenError } from '@/errors/response-errors/forbidden.error';
@@ -17,7 +16,7 @@ import type { PublicUser } from '@/interfaces';
import { License } from '@/license';
import { MfaService } from '@/mfa/mfa.service';
import { PostHogClient } from '@/posthog';
import { AuthenticatedRequest, LoginRequest, UserRequest } from '@/requests';
import { AuthenticatedRequest, AuthlessRequest } from '@/requests';
import { UserService } from '@/services/user.service';
import {
getCurrentAuthenticationMethod,
@@ -40,10 +39,12 @@ export class AuthController {
/** Log in a user */
@Post('/login', { skipAuth: true, rateLimit: true })
async login(req: LoginRequest, res: Response): Promise<PublicUser | undefined> {
const { email, password, mfaCode, mfaRecoveryCode } = req.body;
if (!email) throw new ApplicationError('Email is required to log in');
if (!password) throw new ApplicationError('Password is required to log in');
async login(
req: AuthlessRequest,
res: Response,
@Body payload: LoginRequestDto,
): Promise<PublicUser | undefined> {
const { email, password, mfaCode, mfaRecoveryCode } = payload;
let user: User | undefined;
@@ -117,8 +118,12 @@ export class AuthController {
/** Validate invite token to enable invitee to set up their account */
@Get('/resolve-signup-token', { skipAuth: true })
async resolveSignupToken(req: UserRequest.ResolveSignUp) {
const { inviterId, inviteeId } = req.query;
async resolveSignupToken(
_req: AuthlessRequest,
_res: Response,
@Query payload: ResolveSignupTokenQueryDto,
) {
const { inviterId, inviteeId } = payload;
const isWithinUsersLimit = this.license.isWithinUsersLimit();
if (!isWithinUsersLimit) {
@@ -129,24 +134,6 @@ export class AuthController {
throw new ForbiddenError(RESPONSE_ERROR_MESSAGES.USERS_QUOTA_REACHED);
}
if (!inviterId || !inviteeId) {
this.logger.debug(
'Request to resolve signup token failed because of missing user IDs in query string',
{ inviterId, inviteeId },
);
throw new BadRequestError('Invalid payload');
}
// Postgres validates UUID format
for (const userId of [inviterId, inviteeId]) {
if (!validator.isUUID(userId)) {
this.logger.debug('Request to resolve signup token failed because of invalid user ID', {
userId,
});
throw new BadRequestError('Invalid userId');
}
}
const users = await this.userRepository.findManyByIds([inviterId, inviteeId]);
if (users.length !== 2) {

View File

@@ -17,7 +17,6 @@ import type { FeatureReturnType } from '@/license';
import { License } from '@/license';
import { MfaService } from '@/mfa/mfa.service';
import { Push } from '@/push';
import type { UserSetupPayload } from '@/requests';
import { CacheService } from '@/services/cache/cache.service';
import { PasswordUtility } from '@/services/password.utility';
@@ -48,6 +47,16 @@ const tablesToTruncate = [
'workflows_tags',
];
type UserSetupPayload = {
email: string;
password: string;
firstName: string;
lastName: string;
mfaEnabled?: boolean;
mfaSecret?: string;
mfaRecoveryCodes?: string[];
};
type ResetRequest = Request<
{},
{},

View File

@@ -1,17 +1,17 @@
import { DismissBannerRequestDto, OwnerSetupRequestDto } from '@n8n/api-types';
import { Response } from 'express';
import { Logger } from 'n8n-core';
import validator from 'validator';
import { AuthService } from '@/auth/auth.service';
import config from '@/config';
import { SettingsRepository } from '@/databases/repositories/settings.repository';
import { UserRepository } from '@/databases/repositories/user.repository';
import { GlobalScope, Post, RestController } from '@/decorators';
import { Body, GlobalScope, Post, RestController } from '@/decorators';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { EventService } from '@/events/event.service';
import { validateEntity } from '@/generic-helpers';
import { PostHogClient } from '@/posthog';
import { OwnerRequest } from '@/requests';
import { AuthenticatedRequest } from '@/requests';
import { PasswordUtility } from '@/services/password.utility';
import { UserService } from '@/services/user.service';
@@ -33,8 +33,8 @@ export class OwnerController {
* and enable `isInstanceOwnerSetUp` setting.
*/
@Post('/setup', { skipAuth: true })
async setupOwner(req: OwnerRequest.Post, res: Response) {
const { email, firstName, lastName, password } = req.body;
async setupOwner(req: AuthenticatedRequest, res: Response, @Body payload: OwnerSetupRequestDto) {
const { email, firstName, lastName, password } = payload;
if (config.getEnv('userManagement.isInstanceOwnerSetUp')) {
this.logger.debug(
@@ -43,31 +43,15 @@ export class OwnerController {
throw new BadRequestError('Instance owner already setup');
}
if (!email || !validator.isEmail(email)) {
this.logger.debug('Request to claim instance ownership failed because of invalid email', {
invalidEmail: email,
});
throw new BadRequestError('Invalid email address');
}
const validPassword = this.passwordUtility.validate(password);
if (!firstName || !lastName) {
this.logger.debug(
'Request to claim instance ownership failed because of missing first name or last name in payload',
{ payload: req.body },
);
throw new BadRequestError('First and last names are mandatory');
}
let owner = await this.userRepository.findOneOrFail({
where: { role: 'global:owner' },
});
owner.email = email;
owner.firstName = firstName;
owner.lastName = lastName;
owner.password = await this.passwordUtility.hash(validPassword);
owner.password = await this.passwordUtility.hash(password);
// TODO: move XSS validation out into the DTO class
await validateEntity(owner);
owner = await this.userRepository.save(owner, { transaction: false });
@@ -92,8 +76,13 @@ export class OwnerController {
@Post('/dismiss-banner')
@GlobalScope('banner:dismiss')
async dismissBanner(req: OwnerRequest.DismissBanner) {
const bannerName = 'banner' in req.body ? (req.body.banner as string) : '';
async dismissBanner(
_req: AuthenticatedRequest,
_res: Response,
@Body payload: DismissBannerRequestDto,
) {
const bannerName = payload.banner;
if (!bannerName) return;
return await this.settingsRepository.dismissBanner({ bannerName });
}
}

View File

@@ -1,11 +1,15 @@
import {
ChangePasswordRequestDto,
ForgotPasswordRequestDto,
ResolvePasswordTokenQueryDto,
} from '@n8n/api-types';
import { Response } from 'express';
import { Logger } from 'n8n-core';
import validator from 'validator';
import { AuthService } from '@/auth/auth.service';
import { RESPONSE_ERROR_MESSAGES } from '@/constants';
import { UserRepository } from '@/databases/repositories/user.repository';
import { Get, Post, RestController } from '@/decorators';
import { Body, Get, Post, Query, RestController } from '@/decorators';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { ForbiddenError } from '@/errors/response-errors/forbidden.error';
import { InternalServerError } from '@/errors/response-errors/internal-server.error';
@@ -15,7 +19,7 @@ import { EventService } from '@/events/event.service';
import { ExternalHooks } from '@/external-hooks';
import { License } from '@/license';
import { MfaService } from '@/mfa/mfa.service';
import { PasswordResetRequest } from '@/requests';
import { AuthlessRequest } from '@/requests';
import { PasswordUtility } from '@/services/password.utility';
import { UserService } from '@/services/user.service';
import { isSamlCurrentAuthenticationMethod } from '@/sso.ee/sso-helpers';
@@ -40,7 +44,11 @@ export class PasswordResetController {
* Send a password reset email.
*/
@Post('/forgot-password', { skipAuth: true, rateLimit: { limit: 3 } })
async forgotPassword(req: PasswordResetRequest.Email) {
async forgotPassword(
_req: AuthlessRequest,
_res: Response,
@Body payload: ForgotPasswordRequestDto,
) {
if (!this.mailer.isEmailSetUp) {
this.logger.debug(
'Request to send password reset email failed because emailing was not set up',
@@ -50,22 +58,7 @@ export class PasswordResetController {
);
}
const { email } = req.body;
if (!email) {
this.logger.debug(
'Request to send password reset email failed because of missing email in payload',
{ payload: req.body },
);
throw new BadRequestError('Email is mandatory');
}
if (!validator.isEmail(email)) {
this.logger.debug(
'Request to send password reset email failed because of invalid email in payload',
{ invalidEmail: email },
);
throw new BadRequestError('Invalid email address');
}
const { email } = payload;
// User should just be able to reset password if one is already present
const user = await this.userRepository.findNonShellUser(email);
@@ -138,19 +131,12 @@ export class PasswordResetController {
* Verify password reset token and user ID.
*/
@Get('/resolve-password-token', { skipAuth: true })
async resolvePasswordToken(req: PasswordResetRequest.Credentials) {
const { token } = req.query;
if (!token) {
this.logger.debug(
'Request to resolve password token failed because of missing password reset token',
{
queryString: req.query,
},
);
throw new BadRequestError('');
}
async resolvePasswordToken(
_req: AuthlessRequest,
_res: Response,
@Query payload: ResolvePasswordTokenQueryDto,
) {
const { token } = payload;
const user = await this.authService.resolvePasswordResetToken(token);
if (!user) throw new NotFoundError('');
@@ -170,20 +156,12 @@ export class PasswordResetController {
* Verify password reset token and update password.
*/
@Post('/change-password', { skipAuth: true })
async changePassword(req: PasswordResetRequest.NewPassword, res: Response) {
const { token, password, mfaCode } = req.body;
if (!token || !password) {
this.logger.debug(
'Request to change password failed because of missing user ID or password or reset password token in payload',
{
payload: req.body,
},
);
throw new BadRequestError('Missing user ID or password or reset password token');
}
const validPassword = this.passwordUtility.validate(password);
async changePassword(
req: AuthlessRequest,
res: Response,
@Body payload: ChangePasswordRequestDto,
) {
const { token, password, mfaCode } = payload;
const user = await this.authService.resolvePasswordResetToken(token);
if (!user) throw new NotFoundError('');
@@ -198,7 +176,7 @@ export class PasswordResetController {
if (!validToken) throw new BadRequestError('Invalid MFA token.');
}
const passwordHash = await this.passwordUtility.hash(validPassword);
const passwordHash = await this.passwordUtility.hash(password);
await this.userService.update(user.id, { password: passwordHash });

View File

@@ -11,8 +11,16 @@ import { ProjectRepository } from '@/databases/repositories/project.repository';
import { SharedCredentialsRepository } from '@/databases/repositories/shared-credentials.repository';
import { SharedWorkflowRepository } from '@/databases/repositories/shared-workflow.repository';
import { UserRepository } from '@/databases/repositories/user.repository';
import { GlobalScope, Delete, Get, RestController, Patch, Licensed, Body } from '@/decorators';
import { Param } from '@/decorators/args';
import {
GlobalScope,
Delete,
Get,
RestController,
Patch,
Licensed,
Body,
Param,
} from '@/decorators';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { ForbiddenError } from '@/errors/response-errors/forbidden.error';
import { NotFoundError } from '@/errors/response-errors/not-found.error';

View File

@@ -1,4 +1,4 @@
export { Body } from './args';
export { Body, Query, Param } from './args';
export { RestController } from './rest-controller';
export { Get, Post, Put, Patch, Delete } from './route';
export { Middleware } from './middleware';

View File

@@ -1,7 +1,15 @@
import { VariableListRequestDto } from '@n8n/api-types';
import { Delete, Get, GlobalScope, Licensed, Patch, Post, RestController } from '@/decorators';
import { Query } from '@/decorators/args';
import {
Delete,
Get,
GlobalScope,
Licensed,
Patch,
Post,
Query,
RestController,
} from '@/decorators';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { NotFoundError } from '@/errors/response-errors/not-found.error';
import { VariableCountLimitReachedError } from '@/errors/variable-count-limit-reached.error';

View File

@@ -1,7 +1,6 @@
import type { Scope } from '@n8n/permissions';
import type express from 'express';
import type {
BannerName,
ICredentialDataDecryptedObject,
IDataObject,
ILoadOptions,
@@ -19,7 +18,7 @@ import type { AssignableRole, GlobalRole, User } from '@/databases/entities/user
import type { Variables } from '@/databases/entities/variables';
import type { WorkflowEntity } from '@/databases/entities/workflow-entity';
import type { WorkflowHistory } from '@/databases/entities/workflow-history';
import type { PublicUser, SecretsProvider, SecretsProviderState } from '@/interfaces';
import type { SecretsProvider, SecretsProviderState } from '@/interfaces';
import type { ProjectRole } from './databases/entities/project-relation';
import type { ScopesField } from './services/role.service';
@@ -195,42 +194,6 @@ export declare namespace MeRequest {
export type SurveyAnswers = AuthenticatedRequest<{}, {}, IPersonalizationSurveyAnswersV4>;
}
export interface UserSetupPayload {
email: string;
password: string;
firstName: string;
lastName: string;
mfaEnabled?: boolean;
mfaSecret?: string;
mfaRecoveryCodes?: string[];
}
// ----------------------------------
// /owner
// ----------------------------------
export declare namespace OwnerRequest {
type Post = AuthenticatedRequest<{}, {}, UserSetupPayload, {}>;
type DismissBanner = AuthenticatedRequest<{}, {}, Partial<{ bannerName: BannerName }>, {}>;
}
// ----------------------------------
// password reset endpoints
// ----------------------------------
export declare namespace PasswordResetRequest {
export type Email = AuthlessRequest<{}, {}, Pick<PublicUser, 'email'>>;
export type Credentials = AuthlessRequest<{}, {}, {}, { userId?: string; token?: string }>;
export type NewPassword = AuthlessRequest<
{},
{},
Pick<PublicUser, 'password'> & { token?: string; userId?: string; mfaCode?: string }
>;
}
// ----------------------------------
// /users
// ----------------------------------
@@ -253,18 +216,6 @@ export declare namespace UserRequest {
error?: string;
};
export type ResolveSignUp = AuthlessRequest<
{},
{},
{},
{ inviterId?: string; inviteeId?: string }
>;
export type SignUp = AuthenticatedRequest<
{ id: string },
{ inviterId?: string; inviteeId?: string }
>;
export type Delete = AuthenticatedRequest<
{ id: string; email: string; identifier: string },
{},
@@ -295,21 +246,6 @@ export declare namespace UserRequest {
>;
}
// ----------------------------------
// /login
// ----------------------------------
export type LoginRequest = AuthlessRequest<
{},
{},
{
email: string;
password: string;
mfaCode?: string;
mfaRecoveryCode?: string;
}
>;
// ----------------------------------
// MFA endpoints
// ----------------------------------

View File

@@ -19,6 +19,7 @@ export class PasswordUtility {
return await compare(plaintext, hashed);
}
/** @deprecated. All input validation should move to DTOs */
validate(plaintext?: string) {
if (!plaintext) throw new BadRequestError('Password is mandatory');

View File

@@ -147,6 +147,21 @@ describe('POST /login', () => {
const response = await testServer.authAgentFor(ownerUser).get('/login');
expect(response.statusCode).toBe(200);
});
test('should fail on invalid email in the payload', async () => {
const response = await testServer.authlessAgent.post('/login').send({
email: 'invalid-email',
password: ownerPassword,
});
expect(response.statusCode).toBe(400);
expect(response.body).toEqual({
validation: 'email',
code: 'invalid_string',
message: 'Invalid email',
path: ['email'],
});
});
});
describe('GET /login', () => {

View File

@@ -1,4 +1,4 @@
import { randomInt, randomString } from 'n8n-workflow';
import { randomString } from 'n8n-workflow';
import Container from 'typedi';
import { AuthService } from '@/auth/auth.service';
@@ -239,7 +239,7 @@ describe('Change password with MFA enabled', () => {
.send({
password: newPassword,
token: resetPasswordToken,
mfaCode: randomInt(10),
mfaCode: randomString(10),
})
.expect(404);
});

View File

@@ -2,6 +2,7 @@ import type { Component } from 'vue';
import type { NotificationOptions as ElementNotificationOptions } from 'element-plus';
import type { Connection } from '@jsplumb/core';
import type {
BannerName,
FrontendSettings,
Iso8601DateTimeString,
IUserManagementSettings,
@@ -38,7 +39,6 @@ import type {
ITelemetryTrackProperties,
WorkflowSettings,
IUserSettings,
BannerName,
INodeExecutionData,
INodeProperties,
NodeConnectionType,

View File

@@ -1,6 +1,6 @@
import type { IRestApiContext } from '@/Interface';
import { makeRestApiRequest } from '@/utils/apiUtils';
import type { BannerName } from 'n8n-workflow';
import type { BannerName } from '@n8n/api-types';
export async function dismissBannerPermanently(
context: IRestApiContext,

View File

@@ -1,7 +1,7 @@
<script lang="ts" setup>
import { useUIStore } from '@/stores/ui.store';
import { computed, useSlots } from 'vue';
import type { BannerName } from 'n8n-workflow';
import type { BannerName } from '@n8n/api-types';
import { useI18n } from '@/composables/useI18n';
interface Props {

View File

@@ -53,7 +53,7 @@ import { useWorkflowsStore } from '@/stores/workflows.store';
import { useSettingsStore } from '@/stores/settings.store';
import { useUsersStore } from '@/stores/users.store';
import { dismissBannerPermanently } from '@/api/ui';
import type { BannerName } from 'n8n-workflow';
import type { BannerName } from '@n8n/api-types';
import {
addThemeToBody,
getPreferredTheme,

View File

@@ -2840,13 +2840,6 @@ export interface SecretsHelpersBase {
listSecrets(provider: string): string[];
}
export type BannerName =
| 'V1'
| 'TRIAL_OVER'
| 'TRIAL'
| 'NON_PRODUCTION_LICENSE'
| 'EMAIL_CONFIRMATION';
export type Functionality = 'regular' | 'configuration-node' | 'pairedItem';
export type CallbackManager = CallbackManagerLC;