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

feat: Add manual login option and password reset link for SSO #6328

Merged
Show file tree
Hide file tree
Changes from all 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: 1 addition & 7 deletions packages/cli/src/Interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import type {
ExecutionStatus,
IExecutionsSummary,
FeatureFlags,
IUserSettings,
} from 'n8n-workflow';

import type { ActiveWorkflowRunner } from '@/ActiveWorkflowRunner';
Expand Down Expand Up @@ -478,13 +479,6 @@ export interface IPersonalizationSurveyAnswers {
workArea: string[] | string | null;
}

export interface IUserSettings {
isOnboarded?: boolean;
showUserActivationSurvey?: boolean;
firstSuccessfulWorkflowId?: string;
userActivated?: boolean;
}

export interface IActiveDirectorySettings {
enabled: boolean;
}
Expand Down
5 changes: 4 additions & 1 deletion packages/cli/src/controllers/auth.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,10 @@ export class AuthController {
// attempt to fetch user data with the credentials, but don't log in yet
const preliminaryUser = await handleEmailLogin(email, password);
// if the user is an owner, continue with the login
if (preliminaryUser?.globalRole?.name === 'owner') {
if (
preliminaryUser?.globalRole?.name === 'owner' ||
preliminaryUser?.settings?.allowSSOManualLogin
) {
user = preliminaryUser;
usedAuthenticationMethod = 'email';
} else {
Expand Down
20 changes: 7 additions & 13 deletions packages/cli/src/controllers/passwordReset.controller.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { IsNull, MoreThanOrEqual, Not } from 'typeorm';
import { v4 as uuid } from 'uuid';
import validator from 'validator';
import { Get, Post, RestController } from '@/decorators';
import {
Expand All @@ -25,6 +24,7 @@ import type { IDatabaseCollections, IExternalHooksClass, IInternalHooksClass } f
import { issueCookie } from '@/auth/jwt';
import { isLdapEnabled } from '@/Ldap/helpers';
import { isSamlCurrentAuthenticationMethod } from '../sso/ssoHelpers';
import { UserService } from '../user/user.service';

@RestController()
export class PasswordResetController {
Expand Down Expand Up @@ -103,7 +103,10 @@ export class PasswordResetController {
relations: ['authIdentities', 'globalRole'],
});

if (isSamlCurrentAuthenticationMethod() && user?.globalRole.name !== 'owner') {
if (
isSamlCurrentAuthenticationMethod() &&
!(user?.globalRole.name === 'owner' || user?.settings?.allowSSOManualLogin === true)
) {
this.logger.debug(
'Request to send password reset email failed because login is handled by SAML',
);
Expand All @@ -126,18 +129,9 @@ export class PasswordResetController {
throw new UnprocessableRequestError('forgotPassword.ldapUserPasswordResetUnavailable');
}

user.resetPasswordToken = uuid();

const { id, firstName, lastName, resetPasswordToken } = user;

const resetPasswordTokenExpiration = Math.floor(Date.now() / 1000) + 7200;

await this.userRepository.update(id, { resetPasswordToken, resetPasswordTokenExpiration });

const baseUrl = getInstanceBaseUrl();
const url = new URL(`${baseUrl}/change-password`);
url.searchParams.append('userId', id);
url.searchParams.append('token', resetPasswordToken);
const { id, firstName, lastName } = user;
const url = UserService.generatePasswordResetUrl(user);

try {
await this.mailer.passwordReset({
Expand Down
38 changes: 36 additions & 2 deletions packages/cli/src/controllers/users.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { ErrorReporterProxy as ErrorReporter } from 'n8n-workflow';
import { User } from '@db/entities/User';
import { SharedCredentials } from '@db/entities/SharedCredentials';
import { SharedWorkflow } from '@db/entities/SharedWorkflow';
import { Authorized, NoAuthRequired, Delete, Get, Post, RestController } from '@/decorators';
import { Authorized, NoAuthRequired, Delete, Get, Post, RestController, Patch } from '@/decorators';
import {
addInviteLinkToUser,
generateUserInviteUrl,
Expand All @@ -20,7 +20,7 @@ import { issueCookie } from '@/auth/jwt';
import { BadRequestError, InternalServerError, NotFoundError } from '@/ResponseHelper';
import { Response } from 'express';
import type { Config } from '@/config';
import { UserRequest } from '@/requests';
import { UserRequest, UserSettingsUpdatePayload } from '@/requests';
import type { UserManagementMailer } from '@/UserManagement/email';
import type {
PublicUser,
Expand All @@ -40,6 +40,8 @@ import type {
SharedWorkflowRepository,
UserRepository,
} from '@db/repositories';
import { UserService } from '../user/user.service';
import { plainToInstance } from 'class-transformer';

@Authorized(['global', 'owner'])
@RestController('/users')
Expand Down Expand Up @@ -355,6 +357,38 @@ export class UsersController {
);
}

@Authorized(['global', 'owner'])
@Get('/:id/password-reset-link')
async getUserPasswordResetLink(req: UserRequest.PasswordResetLink) {
const user = await this.userRepository.findOneOrFail({
where: { id: req.params.id },
});
if (!user) {
throw new NotFoundError('User not found');
}
const link = await UserService.generatePasswordResetUrl(user);
return {
link,
};
}

@Authorized(['global', 'owner'])
@Patch('/:id/settings')
async updateUserSettings(req: UserRequest.UserSettingsUpdate) {
const payload = plainToInstance(UserSettingsUpdatePayload, req.body);

const id = req.params.id;

await UserService.updateUserSettings(id, payload);

const user = await this.userRepository.findOneOrFail({
select: ['settings'],
where: { id },
});

return user.settings;
}

/**
* Delete a user. Optionally, designate a transferee for their workflows and credentials.
*/
Expand Down
4 changes: 2 additions & 2 deletions packages/cli/src/databases/entities/User.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,14 @@ import {
BeforeInsert,
} from 'typeorm';
import { IsEmail, IsString, Length } from 'class-validator';
import type { IUser } from 'n8n-workflow';
import type { IUser, IUserSettings } from 'n8n-workflow';
import { Role } from './Role';
import type { SharedWorkflow } from './SharedWorkflow';
import type { SharedCredentials } from './SharedCredentials';
import { NoXss } from '../utils/customValidators';
import { objectRetriever, lowerCaser } from '../utils/transformers';
import { AbstractEntity, jsonColumnType } from './AbstractEntity';
import type { IPersonalizationSurveyAnswers, IUserSettings } from '@/Interfaces';
import type { IPersonalizationSurveyAnswers } from '@/Interfaces';
import type { AuthIdentity } from './AuthIdentity';

export const MIN_PASSWORD_LENGTH = 8;
Expand Down
12 changes: 12 additions & 0 deletions packages/cli/src/requests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,10 @@ export class UserSettingsUpdatePayload {
@IsBoolean({ message: 'userActivated should be a boolean' })
@IsOptional()
userActivated: boolean;

@IsBoolean({ message: 'allowSSOManualLogin should be a boolean' })
@IsOptional()
allowSSOManualLogin?: boolean;
}

export type AuthlessRequest<
Expand Down Expand Up @@ -250,6 +254,14 @@ export declare namespace UserRequest {
{ limit?: number; offset?: number; cursor?: string; includeRole?: boolean }
>;

export type PasswordResetLink = AuthenticatedRequest<{ id: string }, {}, {}, {}>;

export type UserSettingsUpdate = AuthenticatedRequest<
{ id: string },
{},
UserSettingsUpdatePayload
>;

export type Reinvite = AuthenticatedRequest<{ id: string }>;

export type Update = AuthlessRequest<
Expand Down
17 changes: 16 additions & 1 deletion packages/cli/src/user/user.service.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import type { EntityManager, FindOptionsWhere } from 'typeorm';
import { In } from 'typeorm';
import { v4 as uuid } from 'uuid';
import * as Db from '@/Db';
import { User } from '@db/entities/User';
import type { IUserSettings } from '@/Interfaces';
import type { IUserSettings } from 'n8n-workflow';
import { getInstanceBaseUrl } from '../UserManagement/UserManagementHelper';

export class UserService {
static async get(where: FindOptionsWhere<User>): Promise<User | null> {
Expand All @@ -22,4 +24,17 @@ export class UserService {
});
return Db.collections.User.update(id, { settings: { ...currentSettings, ...userSettings } });
}

static async generatePasswordResetUrl(user: User): Promise<string> {
user.resetPasswordToken = uuid();
const { id, resetPasswordToken } = user;
const resetPasswordTokenExpiration = Math.floor(Date.now() / 1000) + 7200;
await Db.collections.User.update(id, { resetPasswordToken, resetPasswordTokenExpiration });

const baseUrl = getInstanceBaseUrl();
const url = new URL(`${baseUrl}/change-password`);
url.searchParams.append('userId', id);
url.searchParams.append('token', resetPasswordToken);
return url.toString();
}
}
17 changes: 16 additions & 1 deletion packages/design-system/src/components/N8nUserInfo/UserInfo.vue
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,14 @@
</div>
<div v-if="!isOwner">
<n8n-text v-if="signInType" size="small" color="text-light">
Sign-in type: {{ signInType }}
Sign-in type:
{{
isSamlLoginEnabled
? settings?.allowSSOManualLogin
? $locale.baseText('settings.sso') + ' + ' + signInType
: $locale.baseText('settings.sso')
: signInType
}}
</n8n-text>
</div>
</div>
Expand Down Expand Up @@ -71,6 +78,14 @@ export default defineComponent({
type: String,
required: false,
},
settings: {
type: Object,
required: false,
},
isSamlLoginEnabled: {
type: Boolean,
required: false,
},
},
computed: {
classes(): Record<string, boolean> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,11 @@
:class="i === sortedUsers.length - 1 ? $style.itemContainer : $style.itemWithBorder"
:data-test-id="`user-list-item-${user.email}`"
>
<n8n-user-info v-bind="user" :isCurrentUser="currentUserId === user.id" />
<n8n-user-info
v-bind="user"
:isCurrentUser="currentUserId === user.id"
:isSamlLoginEnabled="isSamlLoginEnabled"
/>
<div :class="$style.badgeContainer">
<n8n-badge v-if="user.isOwner" theme="tertiary" bold>
{{ t('nds.auth.roles.owner') }}
Expand Down Expand Up @@ -67,6 +71,10 @@ export default defineComponent({
type: Array as PropType<UserAction[]>,
default: () => [],
},
isSamlLoginEnabled: {
type: Boolean,
default: false,
},
},
computed: {
sortedUsers(): IUser[] {
Expand Down
8 changes: 2 additions & 6 deletions packages/editor-ui/src/Interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import type {
IN8nUISettings,
IUserManagementSettings,
WorkflowSettings,
IUserSettings,
} from 'n8n-workflow';
import type { SignInType } from './constants';
import type {
Expand Down Expand Up @@ -561,12 +562,7 @@ export interface IUserResponse {
personalizationAnswers?: IPersonalizationSurveyVersions | null;
isPending: boolean;
signInType?: SignInType;
settings?: {
isOnboarded?: boolean;
showUserActivationSurvey?: boolean;
firstSuccessfulWorkflowId?: string;
userActivated?: boolean;
};
settings?: IUserSettings;
}

export interface CurrentUserResponse extends IUserResponse {
Expand Down
17 changes: 16 additions & 1 deletion packages/editor-ui/src/api/users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,15 @@ export async function updateCurrentUserSettings(
context: IRestApiContext,
settings: IUserResponse['settings'],
): Promise<IUserResponse['settings']> {
return makeRestApiRequest(context, 'PATCH', '/me/settings', settings);
return makeRestApiRequest(context, 'PATCH', '/me/settings', settings as IDataObject);
}

export async function updateOtherUserSettings(
context: IRestApiContext,
userId: string,
settings: IUserResponse['settings'],
): Promise<IUserResponse['settings']> {
return makeRestApiRequest(context, 'PATCH', `/users/${userId}/settings`, settings as IDataObject);
}

export async function updateCurrentUserPassword(
Expand Down Expand Up @@ -144,6 +152,13 @@ export async function getInviteLink(
return makeRestApiRequest(context, 'GET', `/users/${id}/invite-link`);
}

export async function getPasswordResetLink(
context: IRestApiContext,
{ id }: { id: string },
): Promise<{ link: string }> {
return makeRestApiRequest(context, 'GET', `/users/${id}/password-reset-link`);
}

export async function submitPersonalizationSurvey(
context: IRestApiContext,
params: IPersonalizationLatestVersion,
Expand Down
9 changes: 9 additions & 0 deletions packages/editor-ui/src/plugins/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -1169,6 +1169,9 @@
"settings.users.actions.delete": "Delete User",
"settings.users.actions.reinvite": "Resend Invite",
"settings.users.actions.copyInviteLink": "Copy Invite Link",
"settings.users.actions.copyPasswordResetLink": "Copy Password Reset Link",
"settings.users.actions.allowSSOManualLogin": "Allow Manual Login",
"settings.users.actions.disallowSSOManualLogin": "Disallow Manual Login",
"settings.users.deleteWorkflowsAndCredentials": "Delete their workflows and credentials",
"settings.users.emailInvitesSent": "An invite email was sent to {emails}",
"settings.users.emailInvitesSentError": "Could not invite {emails}",
Expand All @@ -1187,6 +1190,12 @@
"settings.users.inviteXUser.inviteUrl": "Create {count} invite links",
"settings.users.inviteUrlCreated": "Invite link copied to clipboard",
"settings.users.inviteUrlCreated.message": "Send the invite link to your invitee for activation",
"settings.users.passwordResetUrlCreated": "Password reset link copied to clipboard",
"settings.users.passwordResetUrlCreated.message": "Send the reset link to your user for them to reset their password",
"settings.users.allowSSOManualLogin": "Manual Login Allowed",
"settings.users.allowSSOManualLogin.message": "User can now login manually and through SSO",
"settings.users.disallowSSOManualLogin": "Manual Login Disallowed",
"settings.users.disallowSSOManualLogin.message": "User must now login through SSO only",
"settings.users.multipleInviteUrlsCreated": "Invite links created",
"settings.users.multipleInviteUrlsCreated.message": "Send the invite links to your invitees for activation",
"settings.users.newEmailsToInvite": "New User Email Addresses",
Expand Down
19 changes: 19 additions & 0 deletions packages/editor-ui/src/stores/users.store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
changePassword,
deleteUser,
getInviteLink,
getPasswordResetLink,
getUsers,
inviteUsers,
login,
Expand All @@ -17,6 +18,7 @@ import {
updateCurrentUser,
updateCurrentUserPassword,
updateCurrentUserSettings,
updateOtherUserSettings,
validatePasswordToken,
validateSignupToken,
} from '@/api/users';
Expand Down Expand Up @@ -251,6 +253,19 @@ export const useUsersStore = defineStore(STORES.USERS, {
this.addUsers([this.currentUser]);
}
},
async updateOtherUserSettings(
userId: string,
settings: IUserResponse['settings'],
): Promise<void> {
const rootStore = useRootStore();
const updatedSettings = await updateOtherUserSettings(
rootStore.getRestApiContext,
userId,
settings,
);
this.users[userId].settings = updatedSettings;
this.addUsers([this.users[userId]]);
},
async updateCurrentUserPassword({
password,
currentPassword,
Expand Down Expand Up @@ -288,6 +303,10 @@ export const useUsersStore = defineStore(STORES.USERS, {
const rootStore = useRootStore();
return getInviteLink(rootStore.getRestApiContext, params);
},
async getUserPasswordResetLink(params: { id: string }): Promise<{ link: string }> {
const rootStore = useRootStore();
return getPasswordResetLink(rootStore.getRestApiContext, params);
},
async submitPersonalizationSurvey(results: IPersonalizationLatestVersion): Promise<void> {
const rootStore = useRootStore();
await submitPersonalizationSurvey(rootStore.getRestApiContext, results);
Expand Down
Loading