1
1
mirror of https://github.com/n8n-io/n8n.git synced 2024-10-05 17:17:45 +03:00

reafactor(core): extract AuthUser into a separate entity

This commit is contained in:
कारतोफ्फेलस्क्रिप्ट™ 2024-05-28 11:04:47 +02:00
parent e07de837b9
commit 2c53a7e93c
No known key found for this signature in database
GPG Key ID: 9300FF7CDEA1FBAA
22 changed files with 114 additions and 57 deletions

View File

@ -20,6 +20,7 @@ import { License } from '@/License';
import { UserRepository } from '@db/repositories/user.repository';
import { AuthProviderSyncHistoryRepository } from '@db/repositories/authProviderSyncHistory.repository';
import { AuthIdentityRepository } from '@db/repositories/authIdentity.repository';
import type { AuthUser } from '@/databases/entities/AuthUser';
/**
* Check whether the LDAP feature is disabled in the instance
@ -187,7 +188,7 @@ export const processUsers = async (
user,
transactionManager,
);
const authIdentity = AuthIdentity.create(savedUser, ldapId);
const authIdentity = AuthIdentity.create(savedUser as AuthUser, ldapId);
return await transactionManager.save(authIdentity);
}),
...toUpdateUsers.map(async ([ldapId, user]) => {
@ -271,9 +272,12 @@ export const getMappingAttributes = (ldapConfig: LdapConfig): string[] => {
};
export const createLdapAuthIdentity = async (user: User, ldapId: string) => {
return await Container.get(AuthIdentityRepository).save(AuthIdentity.create(user, ldapId), {
transaction: false,
});
return await Container.get(AuthIdentityRepository).save(
AuthIdentity.create(user as AuthUser, ldapId),
{
transaction: false,
},
);
};
export const createLdapUserOnLocalDb = async (data: Partial<User>, ldapId: string) => {

View File

@ -15,7 +15,7 @@ import config from '@/config';
import { InternalHooks } from '@/InternalHooks';
import { License } from '@/License';
import { UserRepository } from '@db/repositories/user.repository';
import { AuthUserRepository } from '@db/repositories/authUser.repository';
import { UrlService } from '@/services/url.service';
import type { AuthenticatedRequest } from '@/requests';
@ -94,7 +94,7 @@ async function createApiRouter(
schema: OpenAPIV3.ApiKeySecurityScheme,
): Promise<boolean> => {
const apiKey = req.headers[schema.name.toLowerCase()] as string;
const user = await Container.get(UserRepository).findOne({
const user = await Container.get(AuthUserRepository).findOne({
where: { apiKey },
});

View File

@ -5,8 +5,9 @@ import { JsonWebTokenError, TokenExpiredError } from 'jsonwebtoken';
import config from '@/config';
import { AUTH_COOKIE_NAME, RESPONSE_ERROR_MESSAGES, Time } from '@/constants';
import type { AuthUser } from '@db/entities/AuthUser';
import type { User } from '@db/entities/User';
import { UserRepository } from '@db/repositories/user.repository';
import { AuthUserRepository } from '@db/repositories/authUser.repository';
import { AuthError } from '@/errors/response-errors/auth.error';
import { ForbiddenError } from '@/errors/response-errors/forbidden.error';
import { License } from '@/License';
@ -55,7 +56,7 @@ export class AuthService {
private readonly license: License,
private readonly jwtService: JwtService,
private readonly urlService: UrlService,
private readonly userRepository: UserRepository,
private readonly authUserRepository: AuthUserRepository,
) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
this.authMiddleware = this.authMiddleware.bind(this);
@ -115,13 +116,13 @@ export class AuthService {
});
}
async resolveJwt(token: string, req: AuthenticatedRequest, res: Response): Promise<User> {
async resolveJwt(token: string, req: AuthenticatedRequest, res: Response): Promise<AuthUser> {
const jwtPayload: IssuedJWT = this.jwtService.verify(token, {
algorithms: ['HS256'],
});
// TODO: Use an in-memory ttl-cache to cache the User object for upto a minute
const user = await this.userRepository.findOne({
const user = await this.authUserRepository.findOne({
where: { id: jwtPayload.id },
});
@ -171,7 +172,7 @@ export class AuthService {
return url.toString();
}
async resolvePasswordResetToken(token: string): Promise<User | undefined> {
async resolvePasswordResetToken(token: string): Promise<AuthUser | undefined> {
let decodedToken: PasswordResetToken;
try {
decodedToken = this.jwtService.verify(token);
@ -184,7 +185,7 @@ export class AuthService {
return;
}
const user = await this.userRepository.findOne({
const user = await this.authUserRepository.findOne({
where: { id: decodedToken.sub },
relations: ['authIdentities'],
});

View File

@ -1,12 +1,12 @@
import { Container } from 'typedi';
import type { Response } from 'express';
import type { User } from '@db/entities/User';
import type { AuthUser } from '@db/entities/AuthUser';
import { AuthService } from './auth.service';
// This method is still used by cloud hooks.
// DO NOT DELETE until the hooks have been updated
/** @deprecated Use `AuthService` instead */
export function issueCookie(res: Response, user: User) {
export function issueCookie(res: Response, user: AuthUser) {
return Container.get(AuthService).issueCookie(res, user);
}

View File

@ -3,14 +3,14 @@ import { PasswordUtility } from '@/services/password.utility';
import { Container } from 'typedi';
import { InternalHooks } from '@/InternalHooks';
import { isLdapLoginEnabled } from '@/Ldap/helpers';
import { UserRepository } from '@db/repositories/user.repository';
import { AuthUserRepository } from '@db/repositories/authUser.repository';
import { AuthError } from '@/errors/response-errors/auth.error';
export const handleEmailLogin = async (
email: string,
password: string,
): Promise<User | undefined> => {
const user = await Container.get(UserRepository).findOne({
const user = await Container.get(AuthUserRepository).findOne({
where: { email },
relations: ['authIdentities'],
});

View File

@ -22,6 +22,7 @@ import { ExternalHooks } from '@/ExternalHooks';
import { InternalHooks } from '@/InternalHooks';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { UserRepository } from '@/databases/repositories/user.repository';
import { AuthUserService } from '@/services/authUser.service';
@RestController('/me')
export class MeController {
@ -30,6 +31,7 @@ export class MeController {
private readonly externalHooks: ExternalHooks,
private readonly internalHooks: InternalHooks,
private readonly authService: AuthService,
private readonly authUserService: AuthUserService,
private readonly userService: UserService,
private readonly passwordUtility: PasswordUtility,
private readonly userRepository: UserRepository,
@ -189,7 +191,7 @@ export class MeController {
async createAPIKey(req: AuthenticatedRequest) {
const apiKey = `n8n_api_${randomBytes(40).toString('hex')}`;
await this.userService.update(req.user.id, { apiKey });
await this.authUserService.update(req.user.id, { apiKey });
void this.internalHooks.onApiKeyCreated({
user: req.user,
@ -212,7 +214,7 @@ export class MeController {
*/
@Delete('/api-key')
async deleteAPIKey(req: AuthenticatedRequest) {
await this.userService.update(req.user.id, { apiKey: null });
await this.authUserService.update(req.user.id, { apiKey: null });
void this.internalHooks.onApiKeyDeleted({
user: req.user,

View File

@ -20,7 +20,7 @@ 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';
import { UnprocessableRequestError } from '@/errors/response-errors/unprocessable.error';
import { UserRepository } from '@/databases/repositories/user.repository';
import { AuthUserRepository } from '@/databases/repositories/authUser.repository';
@RestController()
export class PasswordResetController {
@ -35,7 +35,7 @@ export class PasswordResetController {
private readonly urlService: UrlService,
private readonly license: License,
private readonly passwordUtility: PasswordUtility,
private readonly userRepository: UserRepository,
private readonly authUserRepository: AuthUserRepository,
) {}
/**
@ -70,7 +70,7 @@ export class PasswordResetController {
}
// User should just be able to reset password if one is already present
const user = await this.userRepository.findNonShellUser(email);
const user = await this.authUserRepository.findByEmail(email);
if (!user?.isOwner && !this.license.isWithinUsersLimit()) {
this.logger.debug(

View File

@ -1,6 +1,6 @@
import { Column, Entity, ManyToOne, PrimaryColumn, Unique } from '@n8n/typeorm';
import { WithTimestamps } from './AbstractEntity';
import { User } from './User';
import { AuthUser } from './AuthUser';
export type AuthProviderType = 'ldap' | 'email' | 'saml'; // | 'google';
@ -10,8 +10,8 @@ export class AuthIdentity extends WithTimestamps {
@Column()
userId: string;
@ManyToOne(() => User, (user) => user.authIdentities)
user: User;
@ManyToOne(() => AuthUser, (user) => user.authIdentities)
user: AuthUser;
@PrimaryColumn()
providerId: string;
@ -20,7 +20,7 @@ export class AuthIdentity extends WithTimestamps {
providerType: AuthProviderType;
static create(
user: User,
user: AuthUser,
providerId: string,
providerType: AuthProviderType = 'ldap',
): AuthIdentity {

View File

@ -0,0 +1,13 @@
import { Column, Entity, Index, OneToMany } from '@n8n/typeorm';
import type { AuthIdentity } from './AuthIdentity';
import { User } from './User';
@Entity({ name: 'user' })
export class AuthUser extends User {
@Column({ type: String, nullable: true })
@Index({ unique: true })
apiKey?: string | null;
@OneToMany('AuthIdentity', 'user')
authIdentities: AuthIdentity[];
}

View File

@ -17,7 +17,6 @@ import { NoXss } from '../utils/customValidators';
import { objectRetriever, lowerCaser } from '../utils/transformers';
import { WithTimestamps, jsonColumnType } from './AbstractEntity';
import type { IPersonalizationSurveyAnswers } from '@/Interfaces';
import type { AuthIdentity } from './AuthIdentity';
import {
GLOBAL_OWNER_SCOPES,
GLOBAL_MEMBER_SCOPES,
@ -65,6 +64,7 @@ export class User extends WithTimestamps implements IUser {
@IsString({ message: 'Password must be of type string.' })
password: string;
// TODO: move to AuthUser
@Column({
type: jsonColumnType,
nullable: true,
@ -72,6 +72,7 @@ export class User extends WithTimestamps implements IUser {
})
personalizationAnswers: IPersonalizationSurveyAnswers | null;
// TODO: move to AuthUser
@Column({
type: jsonColumnType,
nullable: true,
@ -81,9 +82,6 @@ export class User extends WithTimestamps implements IUser {
@Column()
role: GlobalRole;
@OneToMany('AuthIdentity', 'user')
authIdentities: AuthIdentity[];
@OneToMany('SharedWorkflow', 'user')
sharedWorkflows: SharedWorkflow[];
@ -102,10 +100,6 @@ export class User extends WithTimestamps implements IUser {
this.email = this.email?.toLowerCase() ?? null;
}
@Column({ type: String, nullable: true })
@Index({ unique: true })
apiKey?: string | null;
@Column({ type: Boolean, default: false })
mfaEnabled: boolean;
@ -152,7 +146,7 @@ export class User extends WithTimestamps implements IUser {
}
toJSON() {
const { password, apiKey, mfaSecret, mfaRecoveryCodes, ...rest } = this;
const { password, mfaSecret, mfaRecoveryCodes, ...rest } = this;
return rest;
}

View File

@ -1,6 +1,7 @@
/* eslint-disable @typescript-eslint/naming-convention */
import { AuthIdentity } from './AuthIdentity';
import { AuthProviderSyncHistory } from './AuthProviderSyncHistory';
import { AuthUser } from './AuthUser';
import { CredentialsEntity } from './CredentialsEntity';
import { EventDestinations } from './EventDestinations';
import { ExecutionEntity } from './ExecutionEntity';
@ -25,6 +26,7 @@ import { ProjectRelation } from './ProjectRelation';
export const entities = {
AuthIdentity,
AuthProviderSyncHistory,
AuthUser,
CredentialsEntity,
EventDestinations,
ExecutionEntity,

View File

@ -0,0 +1,16 @@
import { Service } from 'typedi';
import { IsNull, Not, Repository } from '@n8n/typeorm';
import type { AuthUser } from '../entities/AuthUser';
@Service()
export class AuthUserRepository extends Repository<AuthUser> {
async findByEmail(email: string) {
return await this.findOne({
where: {
email,
password: Not(IsNull()),
},
relations: ['authIdentities'],
});
}
}

View File

@ -6,6 +6,7 @@ import type { ListQuery } from '@/requests';
import { type GlobalRole, User } from '../entities/User';
import { Project } from '../entities/Project';
import { ProjectRelation } from '../entities/ProjectRelation';
@Service()
export class UserRepository extends Repository<User> {
constructor(dataSource: DataSource) {

View File

@ -15,6 +15,7 @@ import { Expose } from 'class-transformer';
import { IsBoolean, IsEmail, IsIn, IsOptional, IsString, Length } from 'class-validator';
import { NoXss } from '@db/utils/customValidators';
import type { PublicUser, SecretsProvider, SecretsProviderState } from '@/Interfaces';
import type { AuthUser } from '@db/entities/AuthUser';
import { AssignableRole } from '@db/entities/User';
import type { GlobalRole, User } from '@db/entities/User';
import type { Variables } from '@db/entities/Variables';
@ -22,7 +23,7 @@ import type { WorkflowEntity } from '@db/entities/WorkflowEntity';
import type { CredentialsEntity } from '@db/entities/CredentialsEntity';
import type { WorkflowHistory } from '@db/entities/WorkflowHistory';
import type { Project, ProjectType } from '@db/entities/Project';
import type { ProjectRole } from './databases/entities/ProjectRelation';
import type { ProjectRole } from '@db/entities/ProjectRelation';
import type { Scope } from '@n8n/permissions';
export class UserUpdatePayload implements Pick<User, 'email' | 'firstName' | 'lastName'> {
@ -85,7 +86,7 @@ export type AuthenticatedRequest<
RequestBody = {},
RequestQuery = {},
> = Omit<APIRequest<RouteParams, ResponseBody, RequestBody, RequestQuery>, 'user' | 'cookies'> & {
user: User;
user: AuthUser;
cookies: Record<string, string | undefined>;
};

View File

@ -0,0 +1,18 @@
import { Service } from 'typedi';
import type { AuthUser } from '@db/entities/AuthUser';
import { AuthUserRepository } from '@db/repositories/authUser.repository';
@Service()
export class AuthUserService {
constructor(private readonly authUserRepository: AuthUserRepository) {}
async update(userId: string, data: Partial<AuthUser>) {
const user = await this.authUserRepository.findOneBy({ id: userId });
if (user) {
await this.authUserRepository.save({ ...user, ...data }, { transaction: true });
}
return;
}
}

View File

@ -57,14 +57,14 @@ export class UserService {
withScopes?: boolean;
},
) {
const { password, updatedAt, apiKey, authIdentities, mfaRecoveryCodes, mfaSecret, ...rest } =
user;
const { password, updatedAt, mfaRecoveryCodes, mfaSecret, ...rest } = user;
const ldapIdentity = authIdentities?.find((i) => i.providerType === 'ldap');
// const ldapIdentity = authIdentities?.find((i) => i.providerType === 'ldap');
let publicUser: PublicUser = {
...rest,
signInType: ldapIdentity ? 'ldap' : 'email',
// signInType: ldapIdentity ? 'ldap' : 'email',
signInType: 'email',
hasRecoveryCodesLeft: !!user.mfaRecoveryCodes?.length,
};

View File

@ -25,7 +25,7 @@ import https from 'https';
import type { SamlLoginBinding } from './types';
import { validateMetadata, validateResponse } from './samlValidator';
import { Logger } from '@/Logger';
import { UserRepository } from '@db/repositories/user.repository';
import { AuthUserRepository } from '@db/repositories/authUser.repository';
import { SettingsRepository } from '@db/repositories/settings.repository';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { AuthError } from '@/errors/response-errors/auth.error';
@ -172,7 +172,7 @@ export class SamlService {
const attributes = await this.getAttributesFromLoginResponse(req, binding);
if (attributes.email) {
const lowerCasedEmail = attributes.email.toLowerCase();
const user = await Container.get(UserRepository).findOne({
const user = await Container.get(AuthUserRepository).findOne({
where: { email: lowerCasedEmail },
relations: ['authIdentities'],
});

View File

@ -1,6 +1,9 @@
import { Container } from 'typedi';
import config from '@/config';
import { AuthIdentity } from '@db/entities/AuthIdentity';
import type { AuthUser } from '@db/entities/AuthUser';
import { UserRepository } from '@db/repositories/user.repository';
import { AuthIdentityRepository } from '@db/repositories/authIdentity.repository';
import type { User } from '@db/entities/User';
import { License } from '@/License';
import { PasswordUtility } from '@/services/password.utility';
@ -17,8 +20,6 @@ import {
} from '../ssoHelpers';
import { getServiceProviderConfigTestReturnUrl } from './serviceProvider.ee';
import type { SamlConfiguration } from './types/requests';
import { UserRepository } from '@db/repositories/user.repository';
import { AuthIdentityRepository } from '@db/repositories/authIdentity.repository';
import { InternalServerError } from '@/errors/response-errors/internal-server.error';
import { AuthError } from '@/errors/response-errors/auth.error';
@ -123,7 +124,7 @@ export async function createUserFromSamlAttributes(attributes: SamlUserAttribute
}
export async function updateUserFromSamlAttributes(
user: User,
user: AuthUser,
attributes: SamlUserAttributes,
): Promise<User> {
if (!attributes.email) throw new AuthError('Email is required to update user');

View File

@ -126,7 +126,7 @@ describe('InvitationController', () => {
role: 'global:member',
});
const invalidPaylods = [
const invalidPayloads = [
{
firstName: randomName(),
lastName: randomName(),
@ -155,7 +155,7 @@ describe('InvitationController', () => {
},
];
for (const payload of invalidPaylods) {
for (const payload of invalidPayloads) {
await testServer.authlessAgent
.post(`/invitations/${memberShell.id}/accept`)
.send(payload)

View File

@ -1,4 +1,6 @@
import Container from 'typedi';
import type { SuperAgentTest } from 'supertest';
import type TestAgent from 'supertest/lib/agent';
import { IsNull } from '@n8n/typeorm';
import validator from 'validator';
import type { User } from '@db/entities/User';
@ -13,10 +15,11 @@ import {
import * as testDb from './shared/testDb';
import * as utils from './shared/utils/';
import { addApiKey, createUser, createUserShell } from './shared/db/users';
import Container from 'typedi';
import { UserRepository } from '@db/repositories/user.repository';
import { ProjectRepository } from '@/databases/repositories/project.repository';
jest.setTimeout(1000);
const testServer = utils.setupTestServer({ endpointGroups: ['me'] });
beforeEach(async () => {
@ -25,7 +28,7 @@ beforeEach(async () => {
describe('Owner shell', () => {
let ownerShell: User;
let authOwnerShellAgent: SuperAgentTest;
let authOwnerShellAgent: TestAgent;
beforeEach(async () => {
ownerShell = await createUserShell('global:owner');

View File

@ -1,9 +1,10 @@
import type { Application } from 'express';
import type { ICredentialDataDecryptedObject } from 'n8n-workflow';
import type { SuperAgentTest } from 'supertest';
import type TestAgent from 'supertest/lib/agent';
import type { Server } from 'http';
import type { CredentialsEntity } from '@db/entities/CredentialsEntity';
import type { AuthUser } from '@/databases/entities/AuthUser';
import type { User } from '@db/entities/User';
import type { BooleanLicenseFeature, ICredentialsDb, NumericLicenseFeature } from '@/Interfaces';
import type { LicenseMocker } from './license';
@ -47,9 +48,9 @@ export interface SetupProps {
export interface TestServer {
app: Application;
httpServer: Server;
authAgentFor: (user: User) => SuperAgentTest;
publicApiAgentFor: (user: User) => SuperAgentTest;
authlessAgent: SuperAgentTest;
authAgentFor: (user: AuthUser) => TestAgent;
publicApiAgentFor: (user: AuthUser) => TestAgent;
authlessAgent: TestAgent;
license: LicenseMocker;
}

View File

@ -7,7 +7,7 @@ import { URL } from 'url';
import config from '@/config';
import { AUTH_COOKIE_NAME } from '@/constants';
import type { User } from '@db/entities/User';
import type { AuthUser } from '@/databases/entities/AuthUser';
import { registerController } from '@/decorators';
import { rawBodyReader, bodyParser } from '@/middlewares';
import { PostHogClient } from '@/posthog';
@ -45,7 +45,7 @@ function prefix(pathSegment: string) {
}
const browserId = 'test-browser-id';
function createAgent(app: express.Application, options?: { auth: boolean; user: User }) {
function createAgent(app: express.Application, options?: { auth: boolean; user: AuthUser }) {
const agent = request.agent(app);
void agent.use(prefix(REST_PATH_SEGMENT));
if (options?.auth && options?.user) {
@ -57,7 +57,7 @@ function createAgent(app: express.Application, options?: { auth: boolean; user:
function publicApiAgent(
app: express.Application,
{ user, version = 1 }: { user: User; version?: number },
{ user, version = 1 }: { user: AuthUser; version?: number },
) {
const agent = request.agent(app);
void agent.use(prefix(`${PUBLIC_API_REST_PATH_SEGMENT}/v${version}`));
@ -89,7 +89,7 @@ export const setupTestServer = ({
const testServer: TestServer = {
app,
httpServer: app.listen(0),
authAgentFor: (user: User) => createAgent(app, { auth: true, user }),
authAgentFor: (user) => createAgent(app, { auth: true, user }),
authlessAgent: createAgent(app),
publicApiAgentFor: (user) => publicApiAgent(app, { user }),
license: new LicenseMocker(),