Skip to content
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
144 changes: 123 additions & 21 deletions ProcessMaker/Http/Controllers/Api/UserController.php
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,48 @@ class UserController extends Controller
public $doNotSanitize = [
'username', // has alpha_dash rule
'password',
'firstname', // validated as plain text by User::rules()
'lastname', // validated as plain text by User::rules()
'title', // validated as plain text by User::rules()
];

/**
* Fields accepted when a non-administrative user updates their own profile.
*
* @var array<string>
*/
private const SELF_SERVICE_UPDATE_FIELDS = [
'username',
'password',
'firstname',
'lastname',
'title',
'email',
'address',
'city',
'state',
'postal',
'country',
'phone',
'fax',
'cell',
'timezone',
'datetime_format',
'status',
'avatar',
'preferences_2fa',
'connected_accounts',
'meta',
'valpassword',
];

/**
* Metadata fields accepted during a self-service profile update.
*
* @var array<string>
*/
private const SELF_SERVICE_META_FIELDS = [
'disableRecommendations',
];

/**
Expand Down Expand Up @@ -452,12 +494,22 @@ public function getPinnnedControls(User $user)
*/
public function update(User $user, Request $request)
{
if (!Auth::user()->can('edit', $user)) {
$authenticatedUser = Auth::user();
if (!$authenticatedUser->can('edit', $user)) {
throw new AuthorizationException(__('Not authorized to update this user.'));
}

$request->validate(User::rules($user));
$fields = $request->json()->all();
$isSelfServiceUpdate = $this->authorizeSelfServiceUpdate($authenticatedUser, $user, $fields);
$rules = User::rules($user);
if ($isSelfServiceUpdate) {
$rules['meta'] = ['sometimes', 'array'];
$rules['meta.disableRecommendations'] = ['sometimes', 'boolean'];
}
$request->validate($rules);
if ($isSelfServiceUpdate) {
$fields = $this->normalizeSelfServiceMeta($user, $fields);
}
if (isset($fields['password'])) {
$fields['password'] = Hash::make($fields['password']);
$fields['password_changed_at'] = Carbon::now()->toDateTimeString();
Expand All @@ -466,6 +518,7 @@ public function update(User $user, Request $request)
session()->forget('login-error');
}
$original = $user->getOriginal();
$isLdapUser = $user->meta?->authenticationType === 'ldap';
$user->fill($fields);
if (array_key_exists('cell', $fields)) {
$response = $this->validateCellPhoneNumber($user, $fields['cell']);
Expand All @@ -474,28 +527,24 @@ public function update(User $user, Request $request)
}
}
if ($fields['email'] !== $original['email']) {
$ssoUser = $isLdapUser;
if (class_exists(SsoUser::class)) {
// Check if the user is an SSO user (including SAML)
$ssoUser = SsoUser::where('user_id', $user->id)->exists();

// Check if the user is an LDAP user
if (isset($user->meta?->authenticationType) && $user->meta->authenticationType === 'ldap') {
$ssoUser = true;
}
if ($ssoUser) {
return response([
'message' => __(
"The email can't be edited. This action is only available for SSO-synced users."
),
'errors' => [
'email' => [
__(
"The email can't be edited. This action is only available for SSO-synced users."
),
],
$ssoUser = $ssoUser || SsoUser::where('user_id', $user->id)->exists();
}
if ($ssoUser) {
return response([
'message' => __(
"The email can't be edited. This action is only available for SSO-synced users."
),
'errors' => [
'email' => [
__(
"The email can't be edited. This action is only available for SSO-synced users."
),
],
], 422);
}
],
], 422);
}
if (!isset($fields['valpassword'])) {
return response([
Expand Down Expand Up @@ -564,6 +613,59 @@ public function update(User $user, Request $request)
return response([], 204);
}

/**
* Authorize and constrain self-service profile updates.
*/
private function authorizeSelfServiceUpdate(User $authenticatedUser, User $targetUser, array $fields): bool
{
$isSelfServiceUpdate = $authenticatedUser->id === $targetUser->id
&& !$authenticatedUser->is_administrator
&& !$authenticatedUser->hasPermission('edit-users');

if (!$isSelfServiceUpdate) {
return false;
}

if (!$authenticatedUser->hasPermission('edit-personal-profile')) {
throw new AuthorizationException(__('Not authorized to update this user.'));
}

$disallowedFields = array_diff(array_keys($fields), self::SELF_SERVICE_UPDATE_FIELDS);
if ($disallowedFields !== []) {
throw new AuthorizationException(__('Not authorized to update one or more user fields.'));
}

if (isset($fields['meta']) && is_array($fields['meta'])) {
$disallowedMetaFields = array_diff(array_keys($fields['meta']), self::SELF_SERVICE_META_FIELDS);
if ($disallowedMetaFields !== []) {
throw new AuthorizationException(__('Not authorized to update one or more user fields.'));
}
}

return true;
}

/**
* Merge self-service metadata into the persisted server-managed values.
*/
private function normalizeSelfServiceMeta(User $user, array $fields): array
{
$meta = (array) $user->meta;
if (
array_key_exists('meta', $fields)
&& array_key_exists('disableRecommendations', $fields['meta'])
) {
if ($fields['meta']['disableRecommendations']) {
$meta['disableRecommendations'] = true;
} else {
unset($meta['disableRecommendations']);
}
}
$fields['meta'] = $meta ?: null;

return $fields;
}

/**
* Validate the phone number for SMS two-factor authentication.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,11 @@ public function handle(Request $request, Closure $next)
{
$user = $request->route('user');
$fields = $request->json()->all();
if (($fields['username'] !== $user->getAttribute('username') || in_array('password', $fields)) &&
!Auth::user()->hasPermission('edit-user-and-password') && !Auth::user()->is_administrator) {
$usernameChanged = array_key_exists('username', $fields)
&& $fields['username'] !== $user->getAttribute('username');
$passwordChanged = array_key_exists('password', $fields);
if (($usernameChanged || $passwordChanged) &&
!Auth::user()->hasPermission('edit-user-and-password') && !Auth::user()->is_administrator) {
throw new AuthorizationException(__('Not authorized to update the username and password.'));
}

Expand Down
6 changes: 4 additions & 2 deletions ProcessMaker/Models/User.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
use ProcessMaker\Models\EmptyModel;
use ProcessMaker\Notifications\ResetPassword as ResetPasswordNotification;
use ProcessMaker\Query\Traits\PMQL;
use ProcessMaker\Rules\PlainText;
use ProcessMaker\Rules\StringHasAtLeastOneUpperCaseCharacter;
use ProcessMaker\Traits\Exportable;
use ProcessMaker\Traits\HasAuthorization;
Expand Down Expand Up @@ -184,9 +185,10 @@ public static function rules(self $existing = null)
return [
// The following characters where not included in the regexp: & % ' " ? /
'username' /****/ => ['required', 'regex:/^[a-zA-Z0-9.!#$*+=^_`|~\-@]+$/', 'min:2', 'max:255', $unique],
'firstname' /***/ => ['required', 'max:50'],
'lastname' /****/ => ['required', 'max:50'],
'firstname' /***/ => ['required', 'max:50', new PlainText()],
'lastname' /****/ => ['required', 'max:50', new PlainText()],
'email' /*******/ => ['required', 'email'],
'title' /*******/ => ['nullable', 'max:255', new PlainText()],
'birthdate' /***/ => ['nullable', 'date'],
'phone' /*******/ => ['nullable', 'regex:/^[+\.0-9x\)\(\-\s\/]*$/'],
'fax' /*********/ => ['nullable', 'regex:/^[+\.0-9x\)\(\-\s\/]*$/'],
Expand Down
25 changes: 25 additions & 0 deletions ProcessMaker/Rules/PlainText.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?php

namespace ProcessMaker\Rules;

use Closure;
use Illuminate\Contracts\Validation\ValidationRule;

class PlainText implements ValidationRule
{
/**
* Validate that the value does not contain HTML markup.
*
* @param string $attribute Attribute being validated.
* @param mixed $value Value being validated.
* @param Closure $fail Validation failure callback.
*
* @return void
*/
public function validate(string $attribute, mixed $value, Closure $fail): void
{
if (!is_string($value) || strip_tags($value) !== $value) {
$fail('The :attribute field must contain plain text only.');
}
}
}
11 changes: 8 additions & 3 deletions resources/js/admin/groups/components/UsersInGroupListing.vue
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@
> <template slot="username" slot-scope="props">
<span v-uni-id="props.rowData.id.toString()">{{ props.rowData.username }}</span>
</template>
<template slot="fullname" slot-scope="props">
<span>{{ props.rowData.fullname }}</span>
</template>
<template slot="actions" slot-scope="props">
<div class="actions">
<div class="popout">
Expand Down Expand Up @@ -44,8 +47,10 @@
</template>

<script>
import datatableMixin from "../../../components/common/mixins/datatable";
import escapeHtml from "lodash/escape";
import { createUniqIdsMixin } from "vue-uniq-ids";

import datatableMixin from "../../../components/common/mixins/datatable";
const uniqIdsMixin = createUniqIdsMixin();

export default {
Expand Down Expand Up @@ -74,7 +79,7 @@
},
{
title: () => this.$t("Full Name"),
name: "fullname",
name: "__slot:fullname",
sortField: "firstname"
},
{
Expand Down Expand Up @@ -114,7 +119,7 @@
let that = this;
ProcessMaker.confirmModal(
this.$t("Caution!"),
this.$t('Are you sure you want to delete {{item}}?', {item: data.fullname}),
this.$t('Are you sure you want to delete {{item}}?', {item: escapeHtml(data.fullname)}),
null,
function () {
ProcessMaker.apiClient
Expand Down
11 changes: 8 additions & 3 deletions resources/js/admin/users/components/DeletedUsersListing.vue
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@
<template slot="username" slot-scope="props">
<span v-uni-id="props.rowData.id.toString()">{{ props.rowData.username }}</span>
</template>
<template slot="fullname" slot-scope="props">
<span>{{ props.rowData.fullname }}</span>
</template>
<template slot="avatar" slot-scope="props">
<avatar-image size="25" :input-data="props.rowData" hide-name="true"></avatar-image>
</template>
Expand Down Expand Up @@ -56,10 +59,12 @@


<script>
import escapeHtml from "lodash/escape";
import { createUniqIdsMixin } from "vue-uniq-ids";

import datatableMixin from "../../../components/common/mixins/datatable";
import dataLoadingMixin from "../../../components/common/mixins/apiDataLoading";
import AvatarImage from "../../../components/AvatarImage";
import { createUniqIdsMixin } from "vue-uniq-ids";
const uniqIdsMixin = createUniqIdsMixin();
Vue.component("avatar-image", AvatarImage);

Expand Down Expand Up @@ -93,7 +98,7 @@ export default {
},
{
title: () => this.$t("Full Name"),
name: "fullname",
name: "__slot:fullname",
sortField: "fullname"
},
{
Expand Down Expand Up @@ -169,7 +174,7 @@ export default {

ProcessMaker.confirmModal(
this.$t('Caution!'),
this.$t('Are you sure you want to restore the user {{item}}?', {item: data.fullname}),
this.$t('Are you sure you want to restore the user {{item}}?', {item: escapeHtml(data.fullname)}),
"",
() => {
ProcessMaker.apiClient.put('users/restore', $body).then(response => {
Expand Down
11 changes: 8 additions & 3 deletions resources/js/admin/users/components/UsersListing.vue
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@
<template slot="username" slot-scope="props">
<span v-uni-id="props.rowData.id.toString()">{{ props.rowData.username }}</span>
</template>
<template slot="fullname" slot-scope="props">
<span>{{ props.rowData.fullname }}</span>
</template>
<template slot="avatar" slot-scope="props">
<avatar-image size="25" :input-data="props.rowData" hide-name="true"></avatar-image>
</template>
Expand Down Expand Up @@ -54,12 +57,14 @@


<script>
import escapeHtml from "lodash/escape";
import { createUniqIdsMixin } from "vue-uniq-ids";

import datatableMixin from "../../../components/common/mixins/datatable";
import dataLoadingMixin from "../../../components/common/mixins/apiDataLoading";
import AvatarImage from "../../../components/AvatarImage";
import AddToBundle from "../../../components/shared/AddToBundle.vue";
import EllipsisMenu from "../../../components/shared/EllipsisMenu.vue";
import { createUniqIdsMixin } from "vue-uniq-ids";
const uniqIdsMixin = createUniqIdsMixin();
Vue.component("avatar-image", AvatarImage);

Expand Down Expand Up @@ -98,7 +103,7 @@ export default {
},
{
title: () => this.$t("Full Name"),
name: "fullname",
name: "__slot:fullname",
sortField: "fullname"
},
{
Expand Down Expand Up @@ -181,7 +186,7 @@ export default {
this.$t("Caution!"),
this.$t("Are you sure you want to delete the user") +
" " +
data.fullname +
escapeHtml(data.fullname) +
this.$t("?"),
"",
() => {
Expand Down
Loading
Loading