Skip to content

Commit

Permalink
User Management: switch over to decorators to define routes (n8n-io#3827
Browse files Browse the repository at this point in the history
)
  • Loading branch information
netroy committed Aug 16, 2022
1 parent 23358a3 commit d6be2c9
Show file tree
Hide file tree
Showing 17 changed files with 1,367 additions and 1,328 deletions.
8 changes: 5 additions & 3 deletions packages/cli/src/ResponseHelper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,9 +148,11 @@ const isUniqueConstraintError = (error: Error) =>
* @returns
*/

export function send(processFunction: (req: Request, res: Response) => Promise<any>, raw = false) {
// eslint-disable-next-line consistent-return
return async (req: Request, res: Response) => {
export function send<T, R extends Request, S extends Response>(
processFunction: (req: R, res: S) => Promise<T>,
raw = false,
) {
return async (req: R, res: S) => {
try {
const data = await processFunction(req, res);

Expand Down
191 changes: 191 additions & 0 deletions packages/cli/src/UserManagement/controllers/AuthController.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
/* eslint-disable no-restricted-syntax */
/* eslint-disable import/no-cycle */
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
import type { Request, Response } from 'express';
import { In } from 'typeorm';
import validator from 'validator';
import { LoggerProxy as Logger } from 'n8n-workflow';
import { Db, InternalHooksManager, ResponseHelper } from '../..';
import { issueCookie, resolveJwt } from '../auth/jwt';
import { AUTH_COOKIE_NAME } from '../../constants';
import type { User } from '../../databases/entities/User';
import { Get, Post, RestController } from '../../decorators';
import type { PublicUser } from '../Interfaces';
import type { LoginRequest, UserRequest } from '../../requests';
import { compareHash, sanitizeUser } from '../UserManagementHelper';
import * as config from '../../../config';

@RestController()
export class AuthController {
/**
* Log in a user.
*
* Authless endpoint.
*/
@Post('/login')
async login(req: LoginRequest, res: Response): Promise<PublicUser> {
const { email, password } = req.body;
if (!email) {
throw new Error('Email is required to log in');
}

if (!password) {
throw new Error('Password is required to log in');
}

let user: User | undefined;
try {
user = await Db.collections.User.findOne(
{ email },
{
relations: ['globalRole'],
},
);
} catch (error) {
throw new Error('Unable to access database.');
}

if (!user || !user.password || !(await compareHash(password, user.password))) {
// password is empty until user signs up
const error = new Error('Wrong username or password. Do you have caps lock on?');
// @ts-ignore
error.httpStatusCode = 401;
throw error;
}

await issueCookie(res, user);

return sanitizeUser(user);
}

/**
* Manually check the `n8n-auth` cookie.
*/
@Get('/login')
async loginCurrentUser(req: Request, res: Response): Promise<PublicUser> {
// Manually check the existing cookie.
const cookieContents = req.cookies?.[AUTH_COOKIE_NAME] as string | undefined;

let user: User;
if (cookieContents) {
// If logged in, return user
try {
user = await resolveJwt(cookieContents);

if (!config.get('userManagement.isInstanceOwnerSetUp')) {
res.cookie(AUTH_COOKIE_NAME, cookieContents);
}

return sanitizeUser(user);
} catch (error) {
res.clearCookie(AUTH_COOKIE_NAME);
}
}

if (config.get('userManagement.isInstanceOwnerSetUp')) {
const error = new Error('Not logged in');
// @ts-ignore
error.httpStatusCode = 401;
throw error;
}

try {
user = await Db.collections.User.findOneOrFail({ relations: ['globalRole'] });
} catch (error) {
throw new Error(
'No users found in database - did you wipe the users table? Create at least one user.',
);
}

if (user.email || user.password) {
throw new Error('Invalid database state - user has password set.');
}

await issueCookie(res, user);

return sanitizeUser(user);
}

/**
* Validate invite token to enable invitee to set up their account.
*
* Authless endpoint.
*/
@Get('/resolve-signup-token')
async resolveSignupToken(req: UserRequest.ResolveSignUp) {
const { inviterId, inviteeId } = req.query;

if (!inviterId || !inviteeId) {
Logger.debug(
'Request to resolve signup token failed because of missing user IDs in query string',
{ inviterId, inviteeId },
);
throw new ResponseHelper.ResponseError('Invalid payload', undefined, 400);
}

// Postgres validates UUID format
for (const userId of [inviterId, inviteeId]) {
if (!validator.isUUID(userId)) {
Logger.debug('Request to resolve signup token failed because of invalid user ID', {
userId,
});
throw new ResponseHelper.ResponseError('Invalid userId', undefined, 400);
}
}

const users = await Db.collections.User.find({ where: { id: In([inviterId, inviteeId]) } });

if (users.length !== 2) {
Logger.debug(
'Request to resolve signup token failed because the ID of the inviter and/or the ID of the invitee were not found in database',
{ inviterId, inviteeId },
);
throw new ResponseHelper.ResponseError('Invalid invite URL', undefined, 400);
}

const invitee = users.find((user) => user.id === inviteeId);

if (!invitee || invitee.password) {
Logger.error('Invalid invite URL - invitee already setup', {
inviterId,
inviteeId,
});
throw new ResponseHelper.ResponseError(
'The invitation was likely either deleted or already claimed',
undefined,
400,
);
}

const inviter = users.find((user) => user.id === inviterId);

if (!inviter || !inviter.email || !inviter.firstName) {
Logger.error(
'Request to resolve signup token failed because inviter does not exist or is not set up',
{
inviterId: inviter?.id,
},
);
throw new ResponseHelper.ResponseError('Invalid request', undefined, 400);
}

void InternalHooksManager.getInstance().onUserInviteEmailClick({
user_id: inviteeId,
});

const { firstName, lastName } = inviter;

return { inviter: { firstName, lastName } };
}

/**
* Log out a user.
*
* Authless endpoint.
*/
@Post('/logout')
logout(req: Request, res: Response) {
res.clearCookie(AUTH_COOKIE_NAME);
return { loggedOut: true };
}
}
185 changes: 185 additions & 0 deletions packages/cli/src/UserManagement/controllers/MeController.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
/* eslint-disable import/no-cycle */
import type { Response } from 'express';
import validator from 'validator';
import { LoggerProxy as Logger } from 'n8n-workflow';
import { randomBytes } from 'crypto';
import { Delete, Get, Patch, Post, RestController } from '../../decorators';
import type { AuthenticatedRequest, MeRequest } from '../../requests';
import type { PublicUser } from '../Interfaces';
import { compareHash, hashPassword, sanitizeUser, validatePassword } from '../UserManagementHelper';
import { Db, InternalHooksManager, ResponseHelper } from '../..';
import { User } from '../../databases/entities/User';
import { validateEntity } from '../../GenericHelpers';
import { issueCookie } from '../auth/jwt';

@RestController('/me')
export class MeController {
/**
* Return the logged-in user.
*/
@Get('/')
async getCurrentUser(req: AuthenticatedRequest): Promise<PublicUser> {
return sanitizeUser(req.user);
}

/**
* Update the logged-in user's settings, except password.
*/
@Patch('/')
async updateCurrentUser(req: MeRequest.Settings, res: Response): Promise<PublicUser> {
const { email } = req.body;
if (!email) {
Logger.debug('Request to update user email failed because of missing email in payload', {
userId: req.user.id,
payload: req.body,
});
throw new ResponseHelper.ResponseError('Email is mandatory', undefined, 400);
}

if (!validator.isEmail(email)) {
Logger.debug('Request to update user email failed because of invalid email in payload', {
userId: req.user.id,
invalidEmail: email,
});
throw new ResponseHelper.ResponseError('Invalid email address', undefined, 400);
}

const newUser = new User();

Object.assign(newUser, req.user, req.body);

await validateEntity(newUser);

const user = await Db.collections.User.save(newUser);

Logger.info('User updated successfully', { userId: user.id });

await issueCookie(res, user);

const updatedkeys = Object.keys(req.body);
void InternalHooksManager.getInstance().onUserUpdate({
user_id: req.user.id,
fields_changed: updatedkeys,
});

return sanitizeUser(user);
}

/**
* Update the logged-in user's password.
*/
@Patch('/password')
async updatePassword(req: MeRequest.Password, res: Response) {
const { currentPassword, newPassword } = req.body;

if (typeof currentPassword !== 'string' || typeof newPassword !== 'string') {
throw new ResponseHelper.ResponseError('Invalid payload.', undefined, 400);
}

if (!req.user.password) {
throw new ResponseHelper.ResponseError('Requesting user not set up.');
}

const isCurrentPwCorrect = await compareHash(currentPassword, req.user.password);
if (!isCurrentPwCorrect) {
throw new ResponseHelper.ResponseError(
'Provided current password is incorrect.',
undefined,
400,
);
}

const validPassword = validatePassword(newPassword);

req.user.password = await hashPassword(validPassword);

const user = await Db.collections.User.save(req.user);
Logger.info('Password updated successfully', { userId: user.id });

await issueCookie(res, user);

void InternalHooksManager.getInstance().onUserUpdate({
user_id: req.user.id,
fields_changed: ['password'],
});

return { success: true };
}

/**
* Store the logged-in user's survey answers.
*/
@Post('/survey')
async storeSurveyAnswers(req: MeRequest.SurveyAnswers) {
const { body: personalizationAnswers } = req;

if (!personalizationAnswers) {
Logger.debug('Request to store user personalization survey failed because of empty payload', {
userId: req.user.id,
});
throw new ResponseHelper.ResponseError(
'Personalization answers are mandatory',
undefined,
400,
);
}

await Db.collections.User.save({
id: req.user.id,
personalizationAnswers,
});

Logger.info('User survey updated successfully', { userId: req.user.id });

void InternalHooksManager.getInstance().onPersonalizationSurveySubmitted(
req.user.id,
personalizationAnswers,
);

return { success: true };
}

/**
* Creates an API Key
*/
@Post('/api-key')
async createAPIKey(req: AuthenticatedRequest) {
const apiKey = `n8n_api_${randomBytes(40).toString('hex')}`;

await Db.collections.User.update(req.user.id, { apiKey });

const telemetryData = {
user_id: req.user.id,
public_api: false,
};

void InternalHooksManager.getInstance().onApiKeyCreated(telemetryData);

return { apiKey };
}

/**
* Get an API Key
*/
@Get('/api-key')
async getAPIKey(req: AuthenticatedRequest) {
return { apiKey: req.user.apiKey };
}

/**
* Deletes an API Key
*/
@Delete('/api-key')
async deleteAPIKey(req: AuthenticatedRequest) {
await Db.collections.User.update(req.user.id, { apiKey: null });

const telemetryData = {
user_id: req.user.id,
public_api: false,
};

void InternalHooksManager.getInstance().onApiKeyDeleted(telemetryData);

return { success: true };
}
}
Loading

0 comments on commit d6be2c9

Please sign in to comment.