forked from n8n-io/n8n
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
User Management: switch over to decorators to define routes (n8n-io#3827
- Loading branch information
Showing
17 changed files
with
1,367 additions
and
1,328 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
191 changes: 191 additions & 0 deletions
191
packages/cli/src/UserManagement/controllers/AuthController.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
185
packages/cli/src/UserManagement/controllers/MeController.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 }; | ||
} | ||
} |
Oops, something went wrong.