Skip to content

Commit

Permalink
feat: Add manual login option and password reset link for SSO (#6328)
Browse files Browse the repository at this point in the history
* consolidate IUserSettings in workflow and add allowSSOManualLogin

* add pw reset link to owners ui
  • Loading branch information
flipswitchingmonkey committed May 30, 2023
1 parent 8f0ff46 commit 77e3f15
Show file tree
Hide file tree
Showing 15 changed files with 215 additions and 35 deletions.
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

0 comments on commit 77e3f15

Please sign in to comment.