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,
IExecutionsSummary,
FeatureFlags,
IUserSettings,
} from 'n8n-workflow';
import type { ActiveWorkflowRunner } from '@/ActiveWorkflowRunner';
@ -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;
}

View file

@ -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 {

View file

@ -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 {
@ -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 {
@ -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',
);
@ -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({

View file

@ -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,
@ -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,
@ -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')
@ -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.
*/

View file

@ -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;

View file

@ -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<
@ -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<

View file

@ -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> {
@ -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();
}
}

View file

@ -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>
@ -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> {

View file

@ -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') }}
@ -67,6 +71,10 @@ export default defineComponent({
type: Array as PropType<UserAction[]>,
default: () => [],
},
isSamlLoginEnabled: {
type: Boolean,
default: false,
},
},
computed: {
sortedUsers(): IUser[] {

View file

@ -33,6 +33,7 @@ import type {
IN8nUISettings,
IUserManagementSettings,
WorkflowSettings,
IUserSettings,
} from 'n8n-workflow';
import type { SignInType } from './constants';
import type {
@ -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 {

View file

@ -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(
@ -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,

View file

@ -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}",
@ -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",

View file

@ -2,6 +2,7 @@ import {
changePassword,
deleteUser,
getInviteLink,
getPasswordResetLink,
getUsers,
inviteUsers,
login,
@ -17,6 +18,7 @@ import {
updateCurrentUser,
updateCurrentUserPassword,
updateCurrentUserSettings,
updateOtherUserSettings,
validatePasswordToken,
validateSignupToken,
} from '@/api/users';
@ -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,
@ -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);

View file

@ -50,9 +50,13 @@
:actions="usersListActions"
:users="usersStore.allUsers"
:currentUserId="usersStore.currentUserId"
:isSamlLoginEnabled="ssoStore.isSamlLoginEnabled"
@delete="onDelete"
@reinvite="onReinvite"
@copyInviteLink="onCopyInviteLink"
@copyPasswordResetLink="onCopyPasswordResetLink"
@allowSSOManualLogin="onAllowSSOManualLogin"
@disallowSSOManualLogin="onDisallowSSOManualLogin"
/>
</div>
</div>
@ -106,6 +110,22 @@ export default defineComponent({
label: this.$locale.baseText('settings.users.actions.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() {
this.uiStore.goToUpgrade('users', 'upgrade-users');
},

View file

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