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

* consolidate IUserSettings in workflow and add allowSSOManualLogin

* add pw reset link to owners ui
This commit is contained in:
Michael Auerswald 2023-05-30 12:52:02 +02:00 committed by GitHub
parent 8f0ff460b1
commit 77e3f1551d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 215 additions and 35 deletions

View file

@ -21,6 +21,7 @@ import type {
ExecutionStatus, ExecutionStatus,
IExecutionsSummary, IExecutionsSummary,
FeatureFlags, FeatureFlags,
IUserSettings,
} from 'n8n-workflow'; } from 'n8n-workflow';
import type { ActiveWorkflowRunner } from '@/ActiveWorkflowRunner'; import type { ActiveWorkflowRunner } from '@/ActiveWorkflowRunner';
@ -478,13 +479,6 @@ export interface IPersonalizationSurveyAnswers {
workArea: string[] | string | null; workArea: string[] | string | null;
} }
export interface IUserSettings {
isOnboarded?: boolean;
showUserActivationSurvey?: boolean;
firstSuccessfulWorkflowId?: string;
userActivated?: boolean;
}
export interface IActiveDirectorySettings { export interface IActiveDirectorySettings {
enabled: boolean; enabled: boolean;
} }

View file

@ -76,7 +76,10 @@ export class AuthController {
// attempt to fetch user data with the credentials, but don't log in yet // attempt to fetch user data with the credentials, but don't log in yet
const preliminaryUser = await handleEmailLogin(email, password); const preliminaryUser = await handleEmailLogin(email, password);
// if the user is an owner, continue with the login // 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; user = preliminaryUser;
usedAuthenticationMethod = 'email'; usedAuthenticationMethod = 'email';
} else { } else {

View file

@ -1,5 +1,4 @@
import { IsNull, MoreThanOrEqual, Not } from 'typeorm'; import { IsNull, MoreThanOrEqual, Not } from 'typeorm';
import { v4 as uuid } from 'uuid';
import validator from 'validator'; import validator from 'validator';
import { Get, Post, RestController } from '@/decorators'; import { Get, Post, RestController } from '@/decorators';
import { import {
@ -25,6 +24,7 @@ import type { IDatabaseCollections, IExternalHooksClass, IInternalHooksClass } f
import { issueCookie } from '@/auth/jwt'; import { issueCookie } from '@/auth/jwt';
import { isLdapEnabled } from '@/Ldap/helpers'; import { isLdapEnabled } from '@/Ldap/helpers';
import { isSamlCurrentAuthenticationMethod } from '../sso/ssoHelpers'; import { isSamlCurrentAuthenticationMethod } from '../sso/ssoHelpers';
import { UserService } from '../user/user.service';
@RestController() @RestController()
export class PasswordResetController { export class PasswordResetController {
@ -103,7 +103,10 @@ export class PasswordResetController {
relations: ['authIdentities', 'globalRole'], relations: ['authIdentities', 'globalRole'],
}); });
if (isSamlCurrentAuthenticationMethod() && user?.globalRole.name !== 'owner') { if (
isSamlCurrentAuthenticationMethod() &&
!(user?.globalRole.name === 'owner' || user?.settings?.allowSSOManualLogin === true)
) {
this.logger.debug( this.logger.debug(
'Request to send password reset email failed because login is handled by SAML', 'Request to send password reset email failed because login is handled by SAML',
); );
@ -126,18 +129,9 @@ export class PasswordResetController {
throw new UnprocessableRequestError('forgotPassword.ldapUserPasswordResetUnavailable'); 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 baseUrl = getInstanceBaseUrl();
const url = new URL(`${baseUrl}/change-password`); const { id, firstName, lastName } = user;
url.searchParams.append('userId', id); const url = UserService.generatePasswordResetUrl(user);
url.searchParams.append('token', resetPasswordToken);
try { try {
await this.mailer.passwordReset({ await this.mailer.passwordReset({

View file

@ -5,7 +5,7 @@ import { ErrorReporterProxy as ErrorReporter } from 'n8n-workflow';
import { User } from '@db/entities/User'; import { User } from '@db/entities/User';
import { SharedCredentials } from '@db/entities/SharedCredentials'; import { SharedCredentials } from '@db/entities/SharedCredentials';
import { SharedWorkflow } from '@db/entities/SharedWorkflow'; 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 { import {
addInviteLinkToUser, addInviteLinkToUser,
generateUserInviteUrl, generateUserInviteUrl,
@ -20,7 +20,7 @@ import { issueCookie } from '@/auth/jwt';
import { BadRequestError, InternalServerError, NotFoundError } from '@/ResponseHelper'; import { BadRequestError, InternalServerError, NotFoundError } from '@/ResponseHelper';
import { Response } from 'express'; import { Response } from 'express';
import type { Config } from '@/config'; import type { Config } from '@/config';
import { UserRequest } from '@/requests'; import { UserRequest, UserSettingsUpdatePayload } from '@/requests';
import type { UserManagementMailer } from '@/UserManagement/email'; import type { UserManagementMailer } from '@/UserManagement/email';
import type { import type {
PublicUser, PublicUser,
@ -40,6 +40,8 @@ import type {
SharedWorkflowRepository, SharedWorkflowRepository,
UserRepository, UserRepository,
} from '@db/repositories'; } from '@db/repositories';
import { UserService } from '../user/user.service';
import { plainToInstance } from 'class-transformer';
@Authorized(['global', 'owner']) @Authorized(['global', 'owner'])
@RestController('/users') @RestController('/users')
@ -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. * Delete a user. Optionally, designate a transferee for their workflows and credentials.
*/ */

View file

@ -11,14 +11,14 @@ import {
BeforeInsert, BeforeInsert,
} from 'typeorm'; } from 'typeorm';
import { IsEmail, IsString, Length } from 'class-validator'; 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 { Role } from './Role';
import type { SharedWorkflow } from './SharedWorkflow'; import type { SharedWorkflow } from './SharedWorkflow';
import type { SharedCredentials } from './SharedCredentials'; import type { SharedCredentials } from './SharedCredentials';
import { NoXss } from '../utils/customValidators'; import { NoXss } from '../utils/customValidators';
import { objectRetriever, lowerCaser } from '../utils/transformers'; import { objectRetriever, lowerCaser } from '../utils/transformers';
import { AbstractEntity, jsonColumnType } from './AbstractEntity'; import { AbstractEntity, jsonColumnType } from './AbstractEntity';
import type { IPersonalizationSurveyAnswers, IUserSettings } from '@/Interfaces'; import type { IPersonalizationSurveyAnswers } from '@/Interfaces';
import type { AuthIdentity } from './AuthIdentity'; import type { AuthIdentity } from './AuthIdentity';
export const MIN_PASSWORD_LENGTH = 8; export const MIN_PASSWORD_LENGTH = 8;

View file

@ -40,6 +40,10 @@ export class UserSettingsUpdatePayload {
@IsBoolean({ message: 'userActivated should be a boolean' }) @IsBoolean({ message: 'userActivated should be a boolean' })
@IsOptional() @IsOptional()
userActivated: boolean; userActivated: boolean;
@IsBoolean({ message: 'allowSSOManualLogin should be a boolean' })
@IsOptional()
allowSSOManualLogin?: boolean;
} }
export type AuthlessRequest< export type AuthlessRequest<
@ -250,6 +254,14 @@ export declare namespace UserRequest {
{ limit?: number; offset?: number; cursor?: string; includeRole?: boolean } { 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 Reinvite = AuthenticatedRequest<{ id: string }>;
export type Update = AuthlessRequest< export type Update = AuthlessRequest<

View file

@ -1,8 +1,10 @@
import type { EntityManager, FindOptionsWhere } from 'typeorm'; import type { EntityManager, FindOptionsWhere } from 'typeorm';
import { In } from 'typeorm'; import { In } from 'typeorm';
import { v4 as uuid } from 'uuid';
import * as Db from '@/Db'; import * as Db from '@/Db';
import { User } from '@db/entities/User'; 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 { export class UserService {
static async get(where: FindOptionsWhere<User>): Promise<User | null> { static async get(where: FindOptionsWhere<User>): Promise<User | null> {
@ -22,4 +24,17 @@ export class UserService {
}); });
return Db.collections.User.update(id, { settings: { ...currentSettings, ...userSettings } }); 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();
}
} }

View file

@ -23,7 +23,14 @@
</div> </div>
<div v-if="!isOwner"> <div v-if="!isOwner">
<n8n-text v-if="signInType" size="small" color="text-light"> <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> </n8n-text>
</div> </div>
</div> </div>
@ -71,6 +78,14 @@ export default defineComponent({
type: String, type: String,
required: false, required: false,
}, },
settings: {
type: Object,
required: false,
},
isSamlLoginEnabled: {
type: Boolean,
required: false,
},
}, },
computed: { computed: {
classes(): Record<string, boolean> { classes(): Record<string, boolean> {

View file

@ -7,7 +7,11 @@
:class="i === sortedUsers.length - 1 ? $style.itemContainer : $style.itemWithBorder" :class="i === sortedUsers.length - 1 ? $style.itemContainer : $style.itemWithBorder"
:data-test-id="`user-list-item-${user.email}`" :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"> <div :class="$style.badgeContainer">
<n8n-badge v-if="user.isOwner" theme="tertiary" bold> <n8n-badge v-if="user.isOwner" theme="tertiary" bold>
{{ t('nds.auth.roles.owner') }} {{ t('nds.auth.roles.owner') }}
@ -67,6 +71,10 @@ export default defineComponent({
type: Array as PropType<UserAction[]>, type: Array as PropType<UserAction[]>,
default: () => [], default: () => [],
}, },
isSamlLoginEnabled: {
type: Boolean,
default: false,
},
}, },
computed: { computed: {
sortedUsers(): IUser[] { sortedUsers(): IUser[] {

View file

@ -33,6 +33,7 @@ import type {
IN8nUISettings, IN8nUISettings,
IUserManagementSettings, IUserManagementSettings,
WorkflowSettings, WorkflowSettings,
IUserSettings,
} from 'n8n-workflow'; } from 'n8n-workflow';
import type { SignInType } from './constants'; import type { SignInType } from './constants';
import type { import type {
@ -561,12 +562,7 @@ export interface IUserResponse {
personalizationAnswers?: IPersonalizationSurveyVersions | null; personalizationAnswers?: IPersonalizationSurveyVersions | null;
isPending: boolean; isPending: boolean;
signInType?: SignInType; signInType?: SignInType;
settings?: { settings?: IUserSettings;
isOnboarded?: boolean;
showUserActivationSurvey?: boolean;
firstSuccessfulWorkflowId?: string;
userActivated?: boolean;
};
} }
export interface CurrentUserResponse extends IUserResponse { export interface CurrentUserResponse extends IUserResponse {

View file

@ -105,7 +105,15 @@ export async function updateCurrentUserSettings(
context: IRestApiContext, context: IRestApiContext,
settings: IUserResponse['settings'], settings: IUserResponse['settings'],
): Promise<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( export async function updateCurrentUserPassword(
@ -144,6 +152,13 @@ export async function getInviteLink(
return makeRestApiRequest(context, 'GET', `/users/${id}/invite-link`); 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( export async function submitPersonalizationSurvey(
context: IRestApiContext, context: IRestApiContext,
params: IPersonalizationLatestVersion, params: IPersonalizationLatestVersion,

View file

@ -1169,6 +1169,9 @@
"settings.users.actions.delete": "Delete User", "settings.users.actions.delete": "Delete User",
"settings.users.actions.reinvite": "Resend Invite", "settings.users.actions.reinvite": "Resend Invite",
"settings.users.actions.copyInviteLink": "Copy Invite Link", "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.deleteWorkflowsAndCredentials": "Delete their workflows and credentials",
"settings.users.emailInvitesSent": "An invite email was sent to {emails}", "settings.users.emailInvitesSent": "An invite email was sent to {emails}",
"settings.users.emailInvitesSentError": "Could not invite {emails}", "settings.users.emailInvitesSentError": "Could not invite {emails}",
@ -1187,6 +1190,12 @@
"settings.users.inviteXUser.inviteUrl": "Create {count} invite links", "settings.users.inviteXUser.inviteUrl": "Create {count} invite links",
"settings.users.inviteUrlCreated": "Invite link copied to clipboard", "settings.users.inviteUrlCreated": "Invite link copied to clipboard",
"settings.users.inviteUrlCreated.message": "Send the invite link to your invitee for activation", "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": "Invite links created",
"settings.users.multipleInviteUrlsCreated.message": "Send the invite links to your invitees for activation", "settings.users.multipleInviteUrlsCreated.message": "Send the invite links to your invitees for activation",
"settings.users.newEmailsToInvite": "New User Email Addresses", "settings.users.newEmailsToInvite": "New User Email Addresses",

View file

@ -2,6 +2,7 @@ import {
changePassword, changePassword,
deleteUser, deleteUser,
getInviteLink, getInviteLink,
getPasswordResetLink,
getUsers, getUsers,
inviteUsers, inviteUsers,
login, login,
@ -17,6 +18,7 @@ import {
updateCurrentUser, updateCurrentUser,
updateCurrentUserPassword, updateCurrentUserPassword,
updateCurrentUserSettings, updateCurrentUserSettings,
updateOtherUserSettings,
validatePasswordToken, validatePasswordToken,
validateSignupToken, validateSignupToken,
} from '@/api/users'; } from '@/api/users';
@ -251,6 +253,19 @@ export const useUsersStore = defineStore(STORES.USERS, {
this.addUsers([this.currentUser]); 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({ async updateCurrentUserPassword({
password, password,
currentPassword, currentPassword,
@ -288,6 +303,10 @@ export const useUsersStore = defineStore(STORES.USERS, {
const rootStore = useRootStore(); const rootStore = useRootStore();
return getInviteLink(rootStore.getRestApiContext, params); 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> { async submitPersonalizationSurvey(results: IPersonalizationLatestVersion): Promise<void> {
const rootStore = useRootStore(); const rootStore = useRootStore();
await submitPersonalizationSurvey(rootStore.getRestApiContext, results); await submitPersonalizationSurvey(rootStore.getRestApiContext, results);

View file

@ -50,9 +50,13 @@
:actions="usersListActions" :actions="usersListActions"
:users="usersStore.allUsers" :users="usersStore.allUsers"
:currentUserId="usersStore.currentUserId" :currentUserId="usersStore.currentUserId"
:isSamlLoginEnabled="ssoStore.isSamlLoginEnabled"
@delete="onDelete" @delete="onDelete"
@reinvite="onReinvite" @reinvite="onReinvite"
@copyInviteLink="onCopyInviteLink" @copyInviteLink="onCopyInviteLink"
@copyPasswordResetLink="onCopyPasswordResetLink"
@allowSSOManualLogin="onAllowSSOManualLogin"
@disallowSSOManualLogin="onDisallowSSOManualLogin"
/> />
</div> </div>
</div> </div>
@ -106,6 +110,22 @@ export default defineComponent({
label: this.$locale.baseText('settings.users.actions.delete'), label: this.$locale.baseText('settings.users.actions.delete'),
value: 'delete', value: 'delete',
}, },
{
label: this.$locale.baseText('settings.users.actions.copyPasswordResetLink'),
value: 'copyPasswordResetLink',
},
{
label: this.$locale.baseText('settings.users.actions.allowSSOManualLogin'),
value: 'allowSSOManualLogin',
guard: (user) =>
this.settingsStore.isSamlLoginEnabled && !user.settings?.allowSSOManualLogin,
},
{
label: this.$locale.baseText('settings.users.actions.disallowSSOManualLogin'),
value: 'disallowSSOManualLogin',
guard: (user) =>
this.settingsStore.isSamlLoginEnabled && user.settings?.allowSSOManualLogin === true,
},
]; ];
}, },
}, },
@ -152,6 +172,44 @@ export default defineComponent({
}); });
} }
}, },
async onCopyPasswordResetLink(userId: string) {
const user = this.usersStore.getUserById(userId) as IUser | null;
if (user) {
const url = await this.usersStore.getUserPasswordResetLink(user);
this.copyToClipboard(url.link);
this.showToast({
type: 'success',
title: this.$locale.baseText('settings.users.passwordResetUrlCreated'),
message: this.$locale.baseText('settings.users.passwordResetUrlCreated.message'),
});
}
},
async onAllowSSOManualLogin(userId: string) {
const user = this.usersStore.getUserById(userId) as IUser | null;
if (user?.settings) {
user.settings.allowSSOManualLogin = true;
await this.usersStore.updateOtherUserSettings(userId, user.settings);
this.showToast({
type: 'success',
title: this.$locale.baseText('settings.users.allowSSOManualLogin'),
message: this.$locale.baseText('settings.users.allowSSOManualLogin.message'),
});
}
},
async onDisallowSSOManualLogin(userId: string) {
const user = this.usersStore.getUserById(userId) as IUser | null;
if (user?.settings) {
user.settings.allowSSOManualLogin = false;
await this.usersStore.updateOtherUserSettings(userId, user.settings);
this.showToast({
type: 'success',
title: this.$locale.baseText('settings.users.disallowSSOManualLogin'),
message: this.$locale.baseText('settings.users.disallowSSOManualLogin.message'),
});
}
},
goToUpgrade() { goToUpgrade() {
this.uiStore.goToUpgrade('users', 'upgrade-users'); this.uiStore.goToUpgrade('users', 'upgrade-users');
}, },

View file

@ -1961,6 +1961,14 @@ export interface IUserManagementSettings {
authenticationMethod: AuthenticationMethod; authenticationMethod: AuthenticationMethod;
} }
export interface IUserSettings {
isOnboarded?: boolean;
showUserActivationSurvey?: boolean;
firstSuccessfulWorkflowId?: string;
userActivated?: boolean;
allowSSOManualLogin?: boolean;
}
export interface IPublicApiSettings { export interface IPublicApiSettings {
enabled: boolean; enabled: boolean;
latestVersion: number; latestVersion: number;