Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

User Management: switch over to decorators to define routes #3827

Merged
merged 5 commits into from
Aug 5, 2022
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions packages/cli/src/ResponseHelper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,9 +146,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) => {
netroy marked this conversation as resolved.
Show resolved Hide resolved
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()
netroy marked this conversation as resolved.
Show resolved Hide resolved
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