Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F117459036
D3319.1774838719.diff
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Flag For Later
Award Token
Authored By
Unknown
Size
102 KB
Referenced Files
None
Subscribers
None
D3319.1774838719.diff
View Options
diff --git a/src/.env.example b/src/.env.example
--- a/src/.env.example
+++ b/src/.env.example
@@ -164,6 +164,8 @@
PASSPORT_PRIVATE_KEY=
PASSPORT_PUBLIC_KEY=
+PASSWORD_POLICY=
+
COMPANY_NAME=
COMPANY_ADDRESS=
COMPANY_DETAILS=
diff --git a/src/app/Http/Controllers/API/PasswordPolicyController.php b/src/app/Http/Controllers/API/PasswordPolicyController.php
new file mode 100644
--- /dev/null
+++ b/src/app/Http/Controllers/API/PasswordPolicyController.php
@@ -0,0 +1,66 @@
+<?php
+
+namespace App\Http\Controllers\API;
+
+use App\Http\Controllers\Controller;
+use App\Rules\Password;
+use Illuminate\Http\Request;
+
+class PasswordPolicyController extends Controller
+{
+ /**
+ * Fetch the password policy for the current user account.
+ * The result includes all supported policy rules.
+ *
+ * @param \Illuminate\Http\Request $request
+ *
+ * @return \Illuminate\Http\JsonResponse
+ */
+ public function index(Request $request)
+ {
+ // Get the account owner
+ $owner = $this->guard()->user()->walletOwner();
+
+ // Get the policy
+ $policy = new Password($owner);
+ $rules = $policy->rules(true);
+
+ return response()->json([
+ 'list' => array_values($rules),
+ 'count' => count($rules),
+ ]);
+ }
+
+ /**
+ * Validate the password regarding the defined policies.
+ *
+ * @param \Illuminate\Http\Request $request
+ *
+ * @return \Illuminate\Http\JsonResponse
+ */
+ public function check(Request $request)
+ {
+ $userId = $request->input('user');
+
+ $user = !empty($userId) ? \App\User::find($userId) : null;
+
+ // Get the policy
+ $policy = new Password($user ? $user->walletOwner() : null);
+
+ // Check the password
+ $status = $policy->check($request->input('password'));
+
+ $passed = array_filter(
+ $status,
+ function ($rule) {
+ return !empty($rule['status']);
+ }
+ );
+
+ return response()->json([
+ 'status' => count($passed) == count($status) ? 'success' : 'error',
+ 'list' => array_values($status),
+ 'count' => count($status),
+ ]);
+ }
+}
diff --git a/src/app/Http/Controllers/API/PasswordResetController.php b/src/app/Http/Controllers/API/PasswordResetController.php
--- a/src/app/Http/Controllers/API/PasswordResetController.php
+++ b/src/app/Http/Controllers/API/PasswordResetController.php
@@ -4,6 +4,7 @@
use App\Http\Controllers\Controller;
use App\Jobs\PasswordResetEmail;
+use App\Rules\Password;
use App\User;
use App\VerificationCode;
use Illuminate\Http\Request;
@@ -99,8 +100,11 @@
// with single SQL query (->delete()) instead of two (::destroy())
$this->code = $code;
- // Return user name and email/phone from the codes database on success
- return response()->json(['status' => 'success']);
+ return response()->json([
+ 'status' => 'success',
+ // we need user's ID for e.g. password policy checks
+ 'userId' => $code->user_id,
+ ]);
}
/**
@@ -112,25 +116,23 @@
*/
public function reset(Request $request)
{
- // Validate the request args
+ $v = $this->verify($request);
+ if ($v->status() !== 200) {
+ return $v;
+ }
+
+ $user = $this->code->user;
+
+ // Validate the password
$v = Validator::make(
$request->all(),
- [
- 'password' => 'required|min:4|confirmed',
- ]
+ ['password' => ['required', 'confirmed', new Password($user->walletOwner())]]
);
if ($v->fails()) {
return response()->json(['status' => 'error', 'errors' => $v->errors()], 422);
}
- $v = $this->verify($request);
- if ($v->status() !== 200) {
- return $v;
- }
-
- $user = $this->code->user;
-
// Change the user password
$user->setPasswordAttribute($request->password);
$user->save();
diff --git a/src/app/Http/Controllers/API/SignupController.php b/src/app/Http/Controllers/API/SignupController.php
--- a/src/app/Http/Controllers/API/SignupController.php
+++ b/src/app/Http/Controllers/API/SignupController.php
@@ -9,6 +9,7 @@
use App\Domain;
use App\Plan;
use App\Rules\SignupExternalEmail;
+use App\Rules\Password;
use App\Rules\UserEmailDomain;
use App\Rules\UserEmailLocal;
use App\SignupCode;
@@ -207,7 +208,7 @@
$request->all(),
[
'login' => 'required|min:2',
- 'password' => 'required|min:4|confirmed',
+ 'password' => ['required', 'confirmed', new Password()],
'domain' => 'required',
'voucher' => 'max:32',
]
diff --git a/src/app/Http/Controllers/API/V4/UsersController.php b/src/app/Http/Controllers/API/V4/UsersController.php
--- a/src/app/Http/Controllers/API/V4/UsersController.php
+++ b/src/app/Http/Controllers/API/V4/UsersController.php
@@ -5,6 +5,7 @@
use App\Http\Controllers\RelationController;
use App\Domain;
use App\Group;
+use App\Rules\Password;
use App\Rules\UserEmailDomain;
use App\Rules\UserEmailLocal;
use App\Sku;
@@ -188,6 +189,7 @@
'enableFolders' => $isController && $hasCustomDomain && in_array('beta-shared-folders', $skus),
// TODO: Make 'enableResources' working for wallet controllers that aren't account owners
'enableResources' => $isController && $hasCustomDomain && in_array('beta-resources', $skus),
+ 'enableSettings' => $isController,
'enableUsers' => $isController,
'enableWallets' => $isController,
];
@@ -311,10 +313,6 @@
$user->setAliases($request->aliases);
}
- // TODO: Make sure that UserUpdate job is created in case of entitlements update
- // and no password change. So, for example quota change is applied to LDAP
- // TODO: Review use of $user->save() in the above context
-
DB::commit();
$response = [
@@ -471,6 +469,8 @@
'aliases' => 'array|nullable',
];
+ $controller = ($user ?: $this->guard()->user())->walletOwner();
+
// Handle generated password reset code
if ($code = $request->input('passwordLinkCode')) {
// Accept <short-code>-<code> input
@@ -491,7 +491,7 @@
if (empty($user) || !empty($request->password) || !empty($request->password_confirmation)) {
if (empty($ignorePassword)) {
- $rules['password'] = 'required|min:6|max:255|confirmed';
+ $rules['password'] = ['required', 'confirmed', new Password($controller)];
}
}
@@ -504,8 +504,6 @@
$errors = $v->errors()->toArray();
}
- $controller = $user ? $user->wallet()->owner : $this->guard()->user();
-
// For new user validate email address
if (empty($user)) {
$email = $request->email;
diff --git a/src/app/Rules/Password.php b/src/app/Rules/Password.php
new file mode 100644
--- /dev/null
+++ b/src/app/Rules/Password.php
@@ -0,0 +1,199 @@
+<?php
+
+namespace App\Rules;
+
+use Illuminate\Contracts\Validation\Rule;
+use Illuminate\Support\Facades\Validator;
+use Illuminate\Support\Str;
+
+class Password implements Rule
+{
+ private $message;
+ private $owner;
+
+ /**
+ * Class constructor.
+ *
+ * @param \App\User $owner The account owner (to take the policy from)
+ */
+ public function __construct(?\App\User $owner = null)
+ {
+ $this->owner = $owner;
+ }
+
+ /**
+ * Determine if the validation rule passes.
+ *
+ * @param string $attribute Attribute name
+ * @param mixed $password Password string
+ *
+ * @return bool
+ */
+ public function passes($attribute, $password): bool
+ {
+ foreach ($this->check($password) as $rule) {
+ if (empty($rule['status'])) {
+ $this->message = \trans('validation.password-policy-error');
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ /**
+ * Get the validation error message.
+ *
+ * @return string
+ */
+ public function message(): ?string
+ {
+ return $this->message;
+ }
+
+ /**
+ * Check a password against the policy rules
+ *
+ * @param string $password The password
+ */
+ public function check($password): array
+ {
+ $rules = $this->rules();
+
+ foreach ($rules as $name => $rule) {
+ switch ($name) {
+ case 'min':
+ // Check the min length
+ $pass = strlen($password) >= intval($rule['param']);
+ break;
+
+ case 'max':
+ // Check the max length
+ $length = strlen($password);
+ $pass = $length && $length <= intval($rule['param']);
+ break;
+
+ case 'lower':
+ // Check if password contains a lower-case character
+ $pass = preg_match('/[a-z]/', $password) > 0;
+ break;
+
+ case 'upper':
+ // Check if password contains a upper-case character
+ $pass = preg_match('/[A-Z]/', $password) > 0;
+ break;
+
+ case 'digit':
+ // Check if password contains a digit
+ $pass = preg_match('/[0-9]/', $password) > 0;
+ break;
+
+ case 'special':
+ // Check if password contains a special character
+ $pass = preg_match('/[-~!@#$%^&*_+=`(){}[]|:;"\'`<>,.?\/\\]/', $password) > 0;
+ break;
+
+ default:
+ // Ignore unknown rule name
+ $pass = true;
+ }
+
+ $rules[$name]['status'] = $pass;
+ }
+
+ return $rules;
+ }
+
+ /**
+ * Get the list of rules for a password
+ *
+ * @param bool $all List all supported rules, instead of the enabled ones
+ *
+ * @return array List of rule definitions
+ */
+ public function rules(bool $all = false): array
+ {
+ // All supported password policy rules (with default params)
+ $supported = 'min:6,max:255,lower,upper,digit,special';
+
+ // Get the password policy from the $owner settings
+ if ($this->owner) {
+ $conf = $this->owner->getSetting('password_policy');
+ }
+
+ // Fallback to the configured policy
+ if (empty($conf)) {
+ $conf = \config('app.password_policy');
+ }
+
+ // Default policy, if not set
+ if (empty($conf)) {
+ $conf = 'min:6,max:255';
+ }
+
+ $supported = self::parsePolicy($supported);
+ $conf = self::parsePolicy($conf);
+ $rules = $all ? $supported : $conf;
+
+ foreach ($rules as $idx => $rule) {
+ $param = $rule;
+
+ if ($all && array_key_exists($idx, $conf)) {
+ $param = $conf[$idx];
+ $enabled = true;
+ } else {
+ $enabled = !$all;
+ }
+
+ $rules[$idx] = [
+ 'label' => $idx,
+ 'name' => \trans("app.password-rule-{$idx}", ['param' => $param]),
+ 'param' => $param,
+ 'enabled' => $enabled,
+ ];
+ }
+
+ return $rules;
+ }
+
+ /**
+ * Parse configured policy string
+ *
+ * @param ?string $policy Policy specification
+ *
+ * @return array Policy specification as an array indexed by the policy rule type
+ */
+ public static function parsePolicy(?string $policy): array
+ {
+ $policy = explode(',', strtolower((string) $policy));
+ $policy = array_map('trim', $policy);
+ $policy = array_unique(array_filter($policy));
+
+ return self::mapWithKeys($policy);
+ }
+
+ /**
+ * Convert an array with password policy rules into one indexed by the rule name
+ *
+ * @param array $rules The rules list
+ *
+ * @return array
+ */
+ private static function mapWithKeys(array $rules): array
+ {
+ $result = [];
+
+ foreach ($rules as $rule) {
+ $key = $rule;
+ $value = null;
+
+ if (strpos($key, ':')) {
+ list($key, $value) = explode(':', $key, 2);
+ }
+
+ $result[$key] = $value;
+ }
+
+ return $result;
+ }
+}
diff --git a/src/app/Traits/EntitleableTrait.php b/src/app/Traits/EntitleableTrait.php
--- a/src/app/Traits/EntitleableTrait.php
+++ b/src/app/Traits/EntitleableTrait.php
@@ -255,4 +255,24 @@
return null;
}
+
+ /**
+ * Return the owner of the wallet (account) this entitleable is assigned to
+ *
+ * @return ?\App\User Account owner
+ */
+ public function walletOwner(): ?\App\User
+ {
+ $wallet = $this->wallet();
+
+ if ($wallet) {
+ if ($this instanceof \App\User && $wallet->user_id == $this->id) {
+ return $this;
+ }
+
+ return $wallet->owner;
+ }
+
+ return null;
+ }
}
diff --git a/src/app/Traits/UserConfigTrait.php b/src/app/Traits/UserConfigTrait.php
--- a/src/app/Traits/UserConfigTrait.php
+++ b/src/app/Traits/UserConfigTrait.php
@@ -16,6 +16,7 @@
// TODO: Should we store the default value somewhere in config?
$config['greylist_enabled'] = $this->getSetting('greylist_enabled') !== 'false';
+ $config['password_policy'] = $this->getSetting('password_policy');
return $config;
}
@@ -34,6 +35,20 @@
foreach ($config as $key => $value) {
if ($key == 'greylist_enabled') {
$this->setSetting('greylist_enabled', $value ? 'true' : 'false');
+ } elseif ($key == 'password_policy') {
+ if (!is_string($value) || (strlen($value) && !preg_match('/^[a-z0-9:,]+$/', $value))) {
+ $errors[$key] = \trans('validation.invalid-password-policy');
+ continue;
+ }
+
+ foreach (explode(',', $value) as $rule) {
+ if ($error = $this->validatePasswordPolicyRule($rule)) {
+ $errors[$key] = $error;
+ continue 2;
+ }
+ }
+
+ $this->setSetting('password_policy', $value);
} else {
$errors[$key] = \trans('validation.invalid-config-parameter');
}
@@ -41,4 +56,42 @@
return $errors;
}
+
+ /**
+ * Validates password policy rule.
+ *
+ * @param string $rule Policy rule
+ *
+ * @return ?string An error message on error, Null otherwise
+ */
+ protected function validatePasswordPolicyRule(string $rule): ?string
+ {
+ $regexp = [
+ 'min:[0-9]+', 'max:[0-9]+', 'upper', 'lower', 'digit', 'special',
+ ];
+
+ if (empty($rule) || !preg_match('/^(' . implode('|', $regexp) . ')$/', $rule)) {
+ return \trans('validation.invalid-password-policy');
+ }
+
+ $systemPolicy = \App\Rules\Password::parsePolicy(\config('app.password_policy'));
+
+ // Min/Max values cannot exceed the system defaults, i.e. if system policy
+ // is min:5, user's policy cannot be set to a smaller number.
+ if (!empty($systemPolicy['min'])) {
+ $value = trim(substr($rule, 4));
+ if ($value < $systemPolicy['min']) {
+ return \trans('validation.password-policy-min-len-error', ['min' => $systemPolicy['min']]);
+ }
+ }
+
+ if (!empty($systemPolicy['max'])) {
+ $value = trim(substr($rule, 4));
+ if ($value > $systemPolicy['max']) {
+ return \trans('validation.password-policy-max-len-error', ['max' => $systemPolicy['max']]);
+ }
+ }
+
+ return null;
+ }
}
diff --git a/src/config/app.php b/src/config/app.php
--- a/src/config/app.php
+++ b/src/config/app.php
@@ -285,6 +285,8 @@
'rate' => (float) env('VAT_RATE'),
],
+ 'password_policy' => env('PASSWORD_POLICY'),
+
'payment' => [
'methods_oneoff' => env('PAYMENT_METHODS_ONEOFF', "creditcard,paypal,banktransfer"),
'methods_recurring' => env('PAYMENT_METHODS_RECURRING', "creditcard"),
diff --git a/src/database/migrations/2022_01_13_100000_verification_code_active_column.php b/src/database/migrations/2022_01_13_100000_verification_code_active_column.php
--- a/src/database/migrations/2022_01_13_100000_verification_code_active_column.php
+++ b/src/database/migrations/2022_01_13_100000_verification_code_active_column.php
@@ -29,6 +29,10 @@
*/
public function down()
{
+ if (!Schema::hasTable('verification_codes')) {
+ return;
+ }
+
Schema::table(
'verification_codes',
function (Blueprint $table) {
diff --git a/src/resources/js/app.js b/src/resources/js/app.js
--- a/src/resources/js/app.js
+++ b/src/resources/js/app.js
@@ -388,7 +388,7 @@
loadLangAsync().then(() => app.$mount('#app'))
// Add a axios request interceptor
-window.axios.interceptors.request.use(
+axios.interceptors.request.use(
config => {
// This is the only way I found to change configuration options
// on a running application. We need this for browser testing.
@@ -403,7 +403,7 @@
)
// Add a axios response interceptor for general/validation error handler
-window.axios.interceptors.response.use(
+axios.interceptors.response.use(
response => {
if (response.config.onFinish) {
response.config.onFinish()
@@ -413,7 +413,7 @@
},
error => {
// Do not display the error in a toast message, pass the error as-is
- if (error.config.ignoreErrors) {
+ if (axios.isCancel(error) || error.config.ignoreErrors) {
return Promise.reject(error)
}
diff --git a/src/resources/js/fontawesome.js b/src/resources/js/fontawesome.js
--- a/src/resources/js/fontawesome.js
+++ b/src/resources/js/fontawesome.js
@@ -26,6 +26,7 @@
faPlus,
faSearch,
faSignInAlt,
+ faSlidersH,
faSyncAlt,
faTrashAlt,
faUser,
@@ -61,6 +62,7 @@
faPlus,
faSearch,
faSignInAlt,
+ faSlidersH,
faSquare,
faSyncAlt,
faTrashAlt,
diff --git a/src/resources/js/user/routes.js b/src/resources/js/user/routes.js
--- a/src/resources/js/user/routes.js
+++ b/src/resources/js/user/routes.js
@@ -16,6 +16,7 @@
const MeetComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/Rooms')
const ResourceInfoComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/Resource/Info')
const ResourceListComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/Resource/List')
+const SettingsComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/Settings')
const SharedFolderInfoComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/SharedFolder/Info')
const SharedFolderListComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/SharedFolder/List')
const UserInfoComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/User/Info')
@@ -108,6 +109,12 @@
meta: { requiresAuth: true }
},
{
+ path: '/settings',
+ name: 'settings',
+ component: SettingsComponent,
+ meta: { requiresAuth: true, perm: 'settings' }
+ },
+ {
path: '/shared-folder/:folder',
name: 'shared-folder',
component: SharedFolderInfoComponent,
diff --git a/src/resources/lang/en/app.php b/src/resources/lang/en/app.php
--- a/src/resources/lang/en/app.php
+++ b/src/resources/lang/en/app.php
@@ -110,6 +110,12 @@
'wallet-update-success' => 'User wallet updated successfully.',
'password-reset-code-delete-success' => 'Password reset code deleted successfully.',
+ 'password-rule-min' => 'Minimum password length: :param characters',
+ 'password-rule-max' => 'Maximum password length: :param characters',
+ 'password-rule-lower' => 'Password contains a lower-case character',
+ 'password-rule-upper' => 'Password contains an upper-case character',
+ 'password-rule-digit' => 'Password contains a digit',
+ 'password-rule-special' => 'Password contains a special character',
'wallet-notice-date' => 'With your current subscriptions your account balance will last until about :date (:days).',
'wallet-notice-nocredit' => 'You are out of credit, top up your balance now.',
diff --git a/src/resources/lang/en/ui.php b/src/resources/lang/en/ui.php
--- a/src/resources/lang/en/ui.php
+++ b/src/resources/lang/en/ui.php
@@ -46,6 +46,7 @@
'invitations' => "Invitations",
'profile' => "Your profile",
'resources' => "Resources",
+ 'settings' => "Settings",
'shared-folders' => "Shared folders",
'users' => "User accounts",
'wallet' => "Wallet",
@@ -423,6 +424,7 @@
'pass-input' => "Enter password",
'pass-link' => "Set via link",
'pass-link-label' => "Link:",
+ 'passwordpolicy' => "Password Policy",
'price' => "Price",
'profile-title' => "Your profile",
'profile-delete' => "Delete account",
diff --git a/src/resources/lang/en/validation.php b/src/resources/lang/en/validation.php
--- a/src/resources/lang/en/validation.php
+++ b/src/resources/lang/en/validation.php
@@ -145,6 +145,10 @@
'invalid-config-parameter' => 'The requested configuration parameter is not supported.',
'nameexists' => 'The specified name is not available.',
'nameinvalid' => 'The specified name is invalid.',
+ 'password-policy-error' => 'Specified password does not comply with the policy.',
+ 'invalid-password-policy' => 'Specified password policy is invalid.',
+ 'password-policy-min-len-error' => 'Minimum password length cannot be less than :min.',
+ 'password-policy-max-len-error' => 'Maximum password length cannot be more than :max.',
/*
|--------------------------------------------------------------------------
diff --git a/src/resources/themes/app.scss b/src/resources/themes/app.scss
--- a/src/resources/themes/app.scss
+++ b/src/resources/themes/app.scss
@@ -301,6 +301,7 @@
// Some icons are too big, scale them down
&.link-domains,
&.link-resources,
+ &.link-settings,
&.link-wallet,
&.link-invitations {
svg {
diff --git a/src/resources/themes/forms.scss b/src/resources/themes/forms.scss
--- a/src/resources/themes/forms.scss
+++ b/src/resources/themes/forms.scss
@@ -35,6 +35,17 @@
}
}
+.password-input {
+ ul {
+ svg {
+ width: 0.75em !important;
+ }
+ span {
+ padding: 0 0.05em;
+ }
+ }
+}
+
.range-input {
display: flex;
diff --git a/src/resources/vue/Dashboard.vue b/src/resources/vue/Dashboard.vue
--- a/src/resources/vue/Dashboard.vue
+++ b/src/resources/vue/Dashboard.vue
@@ -32,6 +32,9 @@
<svg-icon icon="comments"></svg-icon><span class="name">{{ $t('dashboard.chat') }}</span>
<span class="badge bg-primary">{{ $t('dashboard.beta') }}</span>
</router-link>
+ <router-link v-if="status.enableSettings" class="card link-settings" :to="{ name: 'settings' }">
+ <svg-icon icon="sliders-h"></svg-icon><span class="name">{{ $t('dashboard.settings') }}</span>
+ </router-link>
<a v-if="webmailURL" class="card link-webmail" :href="webmailURL">
<svg-icon icon="envelope"></svg-icon><span class="name">{{ $t('dashboard.webmail') }}</span>
</a>
diff --git a/src/resources/vue/PasswordReset.vue b/src/resources/vue/PasswordReset.vue
--- a/src/resources/vue/PasswordReset.vue
+++ b/src/resources/vue/PasswordReset.vue
@@ -41,15 +41,8 @@
<p class="card-text">
</p>
<form @submit.prevent="submitStep3" data-validation-prefix="reset_">
- <div class="mb-3">
- <label for="reset_password" class="visually-hidden">{{ $t('form.password') }}</label>
- <input type="password" class="form-control" id="reset_password" :placeholder="$t('form.password')" required v-model="password">
- </div>
- <div class="mb-3">
- <label for="reset_confirm" class="visually-hidden">{{ $t('form.password-confirm') }}</label>
- <input type="password" class="form-control" id="reset_confirm" :placeholder="$t('form.password-confirm')" required v-model="password_confirmation">
- </div>
- <div class="form-group pt-3 mb-3">
+ <password-input class="mb-3" v-model="pass" :user="userId" v-if="userId" :focus="true"></password-input>
+ <div class="form-group pt-1 mb-3">
<label for="secondfactor" class="sr-only">2FA</label>
<div class="input-group">
<span class="input-group-text">
@@ -68,15 +61,20 @@
</template>
<script>
+ import PasswordInput from './Widgets/PasswordInput'
+
export default {
+ components: {
+ PasswordInput
+ },
data() {
return {
email: '',
code: '',
short_code: '',
- password: '',
- password_confirmation: '',
+ pass: {},
secondFactor: '',
+ userId: null,
fromEmail: window.config['mail.from.address']
}
},
@@ -121,6 +119,7 @@
code: this.code,
short_code: this.short_code
}).then(response => {
+ this.userId = response.data.userId
this.displayForm(3, true)
}).catch(error => {
if (bylink === true) {
@@ -137,8 +136,8 @@
axios.post('/api/auth/password-reset', {
code: this.code,
short_code: this.short_code,
- password: this.password,
- password_confirmation: this.password_confirmation,
+ password: this.pass.password,
+ password_confirmation: this.pass.password_confirmation,
secondfactor: this.secondFactor
}).then(response => {
// auto-login and goto dashboard
@@ -151,6 +150,8 @@
card.prev().removeClass('d-none').find('input').first().focus()
card.addClass('d-none').find('form')[0].reset()
+
+ this.userId = null
},
displayForm(step, focus) {
[1, 2, 3].filter(value => value != step).forEach(value => {
diff --git a/src/resources/vue/Settings.vue b/src/resources/vue/Settings.vue
new file mode 100644
--- /dev/null
+++ b/src/resources/vue/Settings.vue
@@ -0,0 +1,79 @@
+<template>
+ <div class="container">
+ <div class="card" id="settings">
+ <div class="card-body">
+ <div class="card-title">
+ {{ $t('dashboard.settings') }}
+ </div>
+ <div class="card-text">
+ <form @submit.prevent="submit">
+ <div class="row mb-3">
+ <label class="col-sm-4 col-form-label">{{ $t('user.passwordpolicy') }}</label>
+ <div class="col-sm-8">
+ <ul id="password_policy" class="list-group ms-1 mt-1">
+ <li v-for="rule in passwordPolicy" :key="rule.label" class="list-group-item border-0 form-check pt-1 pb-1">
+ <input type="checkbox" class="form-check-input" :id="'policy-' + rule.label" :name="rule.label" :checked="rule.enabled">
+ <label :for="'policy-' + rule.label" class="form-check-label pe-2">{{ rule.name.split(':')[0] }}</label>
+ <input type="text" class="form-control form-control-sm w-auto d-inline" v-if="['min', 'max'].includes(rule.label)" :value="rule.param" size="3">
+ </li>
+ </ul>
+ </div>
+ </div>
+ <button class="btn btn-primary" type="submit"><svg-icon icon="check"></svg-icon> {{ $t('btn.submit') }}</button>
+ </form>
+ </div>
+ </div>
+ </div>
+ </div>
+</template>
+
+<script>
+ export default {
+ data() {
+ return {
+ passwordPolicy: []
+ }
+ },
+ created() {
+ this.wallet = this.$store.state.authInfo.wallet
+ },
+ mounted() {
+ this.$root.startLoading()
+
+ axios.get('/api/v4/password-policy')
+ .then(response => {
+ this.$root.stopLoading()
+
+ if (response.data.list) {
+ this.passwordPolicy = response.data.list
+ }
+ })
+ .catch(this.$root.errorHandler)
+ },
+ methods: {
+ submit() {
+ this.$root.clearFormValidation($('#settings form'))
+
+ let password_policy = [];
+
+ $('#password_policy > li > input:checked').each((i, element) => {
+ let entry = element.name
+ const input = $(element.parentNode).find('input[type=text]')[0]
+
+ if (input) {
+ entry += ':' + input.value
+ }
+
+ password_policy.push(entry)
+ })
+
+ let post = { password_policy: password_policy.join(',') }
+
+ axios.post('/api/v4/users/' + this.wallet.user_id + '/config', post)
+ .then(response => {
+ this.$toast.success(response.data.message)
+ })
+ },
+ }
+ }
+</script>
diff --git a/src/resources/vue/Signup.vue b/src/resources/vue/Signup.vue
--- a/src/resources/vue/Signup.vue
+++ b/src/resources/vue/Signup.vue
@@ -83,15 +83,8 @@
</select>
</div>
</div>
+ <password-input class="mb-3" v-model="pass"></password-input>
<div class="mb-3">
- <label for="signup_password" class="visually-hidden">{{ $t('form.password') }}</label>
- <input type="password" class="form-control" id="signup_password" :placeholder="$t('form.password')" required v-model="password">
- </div>
- <div class="mb-3">
- <label for="signup_confirm" class="visually-hidden">{{ $t('form.password-confirm') }}</label>
- <input type="password" class="form-control" id="signup_confirm" :placeholder="$t('form.password-confirm')" required v-model="password_confirmation">
- </div>
- <div class="mb-3 pt-2">
<label for="signup_voucher" class="visually-hidden">{{ $t('signup.voucher') }}</label>
<input type="text" class="form-control" id="signup_voucher" :placeholder="$t('signup.voucher')" v-model="voucher">
</div>
@@ -106,7 +99,12 @@
</template>
<script>
+ import PasswordInput from './Widgets/PasswordInput'
+
export default {
+ components: {
+ PasswordInput
+ },
data() {
return {
email: '',
@@ -115,8 +113,7 @@
code: '',
short_code: '',
login: '',
- password: '',
- password_confirmation: '',
+ pass: {},
domain: '',
domains: [],
invitation: null,
@@ -245,8 +242,8 @@
let post = {
login: this.login,
domain: this.domain,
- password: this.password,
- password_confirmation: this.password_confirmation,
+ password: this.pass.password,
+ password_confirmation: this.pass.password_confirmation,
voucher: this.voucher
}
diff --git a/src/resources/vue/User/Info.vue b/src/resources/vue/User/Info.vue
--- a/src/resources/vue/User/Info.vue
+++ b/src/resources/vue/User/Info.vue
@@ -74,16 +74,7 @@
<input type="checkbox" id="pass-mode-link" value="link" class="btn-check" @change="setPasswordMode">
<label class="btn btn-outline-secondary" for="pass-mode-link">{{ $t('user.pass-link') }}</label>
</div>
- <div v-if="passwordMode == 'input'" :class="isSelf ? '' : 'mt-2'">
- <input id="password" type="password" class="form-control"
- v-model="user.password"
- :placeholder="$t('form.password')"
- >
- <input id="password_confirmation" type="password" class="form-control mt-2"
- v-model="user.password_confirmation"
- :placeholder="$t('form.password-confirm')"
- >
- </div>
+ <password-input v-if="passwordMode == 'input'" :class="isSelf ? '' : 'mt-2'" v-model="user"></password-input>
<div id="password-link" v-if="passwordMode == 'link' || user.passwordLinkCode" class="mt-2">
<span>{{ $t('user.pass-link-label') }}</span> <code>{{ passwordLink }}</code>
<span class="d-inline-block">
@@ -152,6 +143,7 @@
import { Modal } from 'bootstrap'
import ListInput from '../Widgets/ListInput'
import PackageSelect from '../Widgets/PackageSelect'
+ import PasswordInput from '../Widgets/PasswordInput'
import StatusComponent from '../Widgets/Status'
import SubscriptionSelect from '../Widgets/SubscriptionSelect'
@@ -159,6 +151,7 @@
components: {
ListInput,
PackageSelect,
+ PasswordInput,
StatusComponent,
SubscriptionSelect
},
diff --git a/src/resources/vue/User/Profile.vue b/src/resources/vue/User/Profile.vue
--- a/src/resources/vue/User/Profile.vue
+++ b/src/resources/vue/User/Profile.vue
@@ -5,7 +5,7 @@
<div class="card-title">
{{ $t('user.profile-title') }}
<router-link
- v-if="$root.isController(wallet_id)"
+ v-if="$root.isController(wallet.id)"
class="btn btn-outline-danger button-delete float-end"
to="/profile/delete" tag="button"
>
@@ -67,16 +67,7 @@
</div>
<div class="row mb-3">
<label for="password" class="col-sm-4 col-form-label">{{ $t('form.password') }}</label>
- <div class="col-sm-8">
- <input type="password" class="form-control" id="password"
- v-model="profile.password"
- :placeholder="$t('form.password')"
- >
- <input type="password" class="form-control mt-2" id="password_confirmation"
- v-model="profile.password_confirmation"
- :placeholder="$t('form.password-confirm')"
- >
- </div>
+ <password-input class="col-sm-8" v-model="profile"></password-input>
</div>
<button class="btn btn-primary button-submit mt-2" type="submit"><svg-icon icon="check"></svg-icon> {{ $t('btn.submit') }}</button>
</form>
@@ -87,17 +78,22 @@
</template>
<script>
+ import PasswordInput from '../Widgets/PasswordInput'
+
export default {
+ components: {
+ PasswordInput
+ },
data() {
return {
profile: {},
user_id: null,
- wallet_id: null,
+ wallet: {},
countries: window.config.countries
}
},
created() {
- this.wallet_id = this.$store.state.authInfo.wallet.id
+ this.wallet = this.$store.state.authInfo.wallet
this.profile = this.$store.state.authInfo.settings
this.user_id = this.$store.state.authInfo.id
},
diff --git a/src/resources/vue/Widgets/PasswordInput.vue b/src/resources/vue/Widgets/PasswordInput.vue
new file mode 100644
--- /dev/null
+++ b/src/resources/vue/Widgets/PasswordInput.vue
@@ -0,0 +1,94 @@
+<template>
+ <div class="password-input">
+ <div :id="prefix + 'password_input'">
+ <input type="password"
+ class="form-control"
+ autocomplete="new-password"
+ :id="prefix + 'password'"
+ :placeholder="$t('form.password')"
+ v-model="password"
+ @input="onInput"
+ >
+ <input type="password"
+ class="form-control mt-2"
+ autocomplete="new-password"
+ :id="prefix + 'password_confirmation'"
+ :placeholder="$t('form.password-confirm')"
+ v-model="password_confirmation"
+ @input="onInputConfirm"
+ >
+ </div>
+ <ul v-if="policy.length" :id="prefix + 'password_policy'" class="list-group pt-2">
+ <li v-for="rule in policy" :key="rule.label" class="list-group-item border-0 p-0">
+ <svg-icon v-if="rule.status" icon="check" class="text-success"></svg-icon>
+ <span v-else class="text-secondary">•</span>
+ <small class="ps-1 form-text">{{ rule.name }}</small>
+ </li>
+ </ul>
+ </div>
+</template>
+
+<script>
+ export default {
+ props: {
+ focus: { type: Boolean, default: false },
+ value: { type: Object, default: () => {} },
+ user: { type: [String, Number], default: '' }
+ },
+ data() {
+ return {
+ password: '',
+ password_confirmation: '',
+ policy: [],
+ prefix: ''
+ }
+ },
+ mounted() {
+ this.checkPolicy('')
+
+ const input = $('#password')[0]
+
+ this.prefix = $(input.form).data('validation-prefix') || ''
+
+ $(input.form).on('reset', () => { this.checkPolicy('') })
+
+ if (this.focus) {
+ input.focus()
+ }
+ },
+ methods: {
+ checkPolicy(password) {
+ if (this.cancelToken) {
+ this.cancelToken.cancel()
+ }
+
+ const post = { password, user: this.user }
+
+ if (!post.user && this.$store.state.authInfo) {
+ post.user = this.$store.state.authInfo.id
+ }
+
+ const cancelToken = axios.CancelToken;
+ this.cancelToken = cancelToken.source();
+
+ axios.post('/api/auth/password-policy/check', post, { cancelToken: this.cancelToken.token })
+ .then(response => {
+ if (response.data.list) {
+ this.policy = response.data.list
+ }
+ })
+ },
+ onInput(event) {
+ this.checkPolicy(event.target.value)
+ this.update()
+ },
+ onInputConfirm(event) {
+ this.update()
+ },
+ update() {
+ const update = { password: this.password, password_confirmation: this.password_confirmation }
+ this.$emit('input', {...this.value, ...update})
+ }
+ }
+ }
+</script>
diff --git a/src/routes/api.php b/src/routes/api.php
--- a/src/routes/api.php
+++ b/src/routes/api.php
@@ -42,6 +42,8 @@
'prefix' => $prefix . 'api/auth'
],
function ($router) {
+ Route::post('password-policy/check', 'API\PasswordPolicyController@check');
+
Route::post('password-reset/init', 'API\PasswordResetController@init');
Route::post('password-reset/verify', 'API\PasswordResetController@verify');
Route::post('password-reset', 'API\PasswordResetController@reset');
@@ -100,6 +102,7 @@
Route::get('wallets/{id}/receipts', 'API\V4\WalletsController@receipts');
Route::get('wallets/{id}/receipts/{receipt}', 'API\V4\WalletsController@receiptDownload');
+ Route::get('password-policy', 'API\PasswordPolicyController@index');
Route::post('password-reset/code', 'API\PasswordResetController@codeCreate');
Route::delete('password-reset/code/{id}', 'API\PasswordResetController@codeDelete');
diff --git a/src/tests/Browser/Pages/Settings.php b/src/tests/Browser/Pages/Settings.php
new file mode 100644
--- /dev/null
+++ b/src/tests/Browser/Pages/Settings.php
@@ -0,0 +1,44 @@
+<?php
+
+namespace Tests\Browser\Pages;
+
+use Laravel\Dusk\Page;
+
+class Settings extends Page
+{
+ /**
+ * Get the URL for the page.
+ *
+ * @return string
+ */
+ public function url(): string
+ {
+ return '/settings';
+ }
+
+ /**
+ * Assert that the browser is on the page.
+ *
+ * @param \Laravel\Dusk\Browser $browser The browser object
+ *
+ * @return void
+ */
+ public function assert($browser)
+ {
+ $browser->waitFor('@form')
+ ->waitUntilMissing('.app-loader');
+ }
+
+ /**
+ * Get the element shortcuts for the page.
+ *
+ * @return array
+ */
+ public function elements(): array
+ {
+ return [
+ '@app' => '#app',
+ '@form' => '#settings form',
+ ];
+ }
+}
diff --git a/src/tests/Browser/PasswordResetTest.php b/src/tests/Browser/PasswordResetTest.php
--- a/src/tests/Browser/PasswordResetTest.php
+++ b/src/tests/Browser/PasswordResetTest.php
@@ -36,7 +36,7 @@
/**
* Test the link from logon to password-reset page
*/
- public function testPasswordResetLinkOnLogon(): void
+ public function testLinkOnLogon(): void
{
$this->browse(function (Browser $browser) {
$browser->visit(new Home());
@@ -52,7 +52,7 @@
/**
* Test 1st step of password-reset
*/
- public function testPasswordResetStep1(): void
+ public function testStep1(): void
{
$user = $this->getTestUser('passwordresettestdusk@' . \config('app.domain'));
$user->setSetting('external_email', 'external@domain.tld');
@@ -105,9 +105,9 @@
/**
* Test 2nd Step of the password reset process
*
- * @depends testPasswordResetStep1
+ * @depends testStep1
*/
- public function testPasswordResetStep2(): void
+ public function testStep2(): void
{
$user = $this->getTestUser('passwordresettestdusk@' . \config('app.domain'));
$user->setSetting('external_email', 'external@domain.tld');
@@ -180,41 +180,43 @@
/**
* Test 3rd Step of the password reset process
*
- * @depends testPasswordResetStep2
+ * @depends testStep2
*/
- public function testPasswordResetStep3(): void
+ public function testStep3(): void
{
$user = $this->getTestUser('passwordresettestdusk@' . \config('app.domain'));
$user->setSetting('external_email', 'external@domain.tld');
+ $user->setSetting('password_policy', 'upper,digit');
$this->browse(function (Browser $browser) {
- $browser->assertVisible('@step3');
+ $browser->assertVisible('@step3')
+ ->clearToasts();
// Here we expect 2 text inputs, Back and Continue buttons
- $browser->with('@step3', function ($step) {
- $step->assertVisible('#reset_password');
- $step->assertVisible('#reset_confirm');
- $step->assertVisible('[type=button]');
- $step->assertVisible('[type=submit]');
- $step->assertFocused('#reset_password');
+ $browser->with('@step3', function (Browser $step) {
+ $step->assertVisible('#reset_password')
+ ->assertVisible('#reset_password_confirmation')
+ ->assertVisible('[type=button]')
+ ->assertVisible('[type=submit]')
+ ->assertFocused('#reset_password');
});
// Test Back button
- $browser->click('@step3 [type=button]');
- $browser->waitFor('@step2');
- $browser->assertFocused('@step2 #reset_short_code');
- $browser->assertMissing('@step3');
- $browser->assertMissing('@step1');
+ $browser->click('@step3 [type=button]')
+ ->waitFor('@step2')
+ ->assertFocused('@step2 #reset_short_code')
+ ->assertMissing('@step3')
+ ->assertMissing('@step1');
// TODO: Test form reset when going back
// Because the verification code is removed in tearDown()
// we'll start from the beginning (Step 1)
- $browser->click('@step2 [type=button]');
- $browser->waitFor('@step1');
- $browser->assertFocused('@step1 #reset_email');
- $browser->assertMissing('@step3');
- $browser->assertMissing('@step2');
+ $browser->click('@step2 [type=button]')
+ ->waitFor('@step1')
+ ->assertFocused('@step1 #reset_email')
+ ->assertMissing('@step3')
+ ->assertMissing('@step2');
// Submit valid data
$browser->with('@step1', function ($step) {
@@ -222,8 +224,8 @@
$step->click('[type=submit]');
});
- $browser->waitFor('@step2');
- $browser->waitUntilMissing('@step2 #reset_code[value=""]');
+ $browser->waitFor('@step2')
+ ->waitUntilMissing('@step2 #reset_code[value=""]');
// Submit valid code again
$browser->with('@step2', function ($step) {
@@ -241,18 +243,27 @@
// Submit invalid data
$browser->with('@step3', function ($step) use ($browser) {
- $step->assertFocused('#reset_password');
-
- $step->type('#reset_password', '12345678');
- $step->type('#reset_confirm', '123456789');
+ $step->assertFocused('#reset_password')
+ ->whenAvailable('#reset_password_policy', function (Browser $browser) {
+ $browser->assertElementsCount('li', 2)
+ ->assertMissing('li:first-child svg.text-success')
+ ->assertSeeIn('li:first-child small', "Password contains an upper-case character")
+ ->assertMissing('li:last-child svg.text-success')
+ ->assertSeeIn('li:last-child small', "Password contains a digit");
+ })
+ ->type('#reset_password', 'A2345678')
+ ->type('#reset_password_confirmation', '123456789')
+ ->with('#reset_password_policy', function (Browser $browser) {
+ $browser->waitFor('li:first-child svg.text-success')
+ ->waitFor('li:last-child svg.text-success');
+ });
$step->click('[type=submit]');
$browser->waitFor('.toast-error');
$step->waitFor('#reset_password.is-invalid')
- ->assertVisible('#reset_password.is-invalid')
- ->assertVisible('#reset_password + .invalid-feedback')
+ ->assertVisible('#reset_password_input .invalid-feedback')
->assertFocused('#reset_password');
$browser->click('.toast-error'); // remove the toast
@@ -260,9 +271,8 @@
// Submit valid data
$browser->with('@step3', function ($step) {
- $step->type('#reset_confirm', '12345678');
-
- $step->click('[type=submit]');
+ $step->type('#reset_password_confirmation', 'A2345678')
+ ->click('[type=submit]');
});
$browser->waitUntilMissing('@step3');
@@ -273,4 +283,12 @@
// FIXME: Is it enough to be sure user is logged in?
});
}
+
+ /**
+ * Test password reset process for a user with 2FA enabled.
+ */
+ public function testResetWith2FA(): void
+ {
+ $this->markTestIncomplete();
+ }
}
diff --git a/src/tests/Browser/SettingsTest.php b/src/tests/Browser/SettingsTest.php
new file mode 100644
--- /dev/null
+++ b/src/tests/Browser/SettingsTest.php
@@ -0,0 +1,100 @@
+<?php
+
+namespace Tests\Browser;
+
+use Tests\Browser;
+use Tests\Browser\Components\Menu;
+use Tests\Browser\Components\Toast;
+use Tests\Browser\Pages\Dashboard;
+use Tests\Browser\Pages\Home;
+use Tests\Browser\Pages\Settings;
+use Tests\TestCaseDusk;
+
+class SettingsTest extends TestCaseDusk
+{
+ /**
+ * Test settings page (unauthenticated)
+ */
+ public function testSettingsUnauth(): void
+ {
+ // Test that the page requires authentication
+ $this->browse(function (Browser $browser) {
+ $browser->visit('/settings')->on(new Home());
+ });
+ }
+
+ /**
+ * Test settings "box" on Dashboard
+ */
+ public function testDashboard(): void
+ {
+ $this->browse(function (Browser $browser) {
+ // Test a user that is not an account owner
+ $browser->visit(new Home())
+ ->submitLogon('jack@kolab.org', 'simple123', true)
+ ->on(new Dashboard())
+ ->assertMissing('@links .link-settings .name')
+ ->visit('/settings')
+ ->assertErrorPage(403)
+ ->within(new Menu(), function (Browser $browser) {
+ $browser->clickMenuItem('logout');
+ });
+
+ // Test the account owner
+ $browser->waitForLocation('/login')
+ ->on(new Home())
+ ->submitLogon('john@kolab.org', 'simple123', true)
+ ->on(new Dashboard())
+ ->assertSeeIn('@links .link-settings .name', 'Settings');
+ });
+ }
+
+ /**
+ * Test Settings page
+ *
+ * @depends testDashboard
+ */
+ public function testSettings(): void
+ {
+ $john = $this->getTestUser('john@kolab.org');
+ $john->setSetting('password_policy', 'min:5,max:100,lower');
+
+ $this->browse(function (Browser $browser) {
+ $browser->click('@links .link-settings')
+ ->on(new Settings())
+ ->assertSeeIn('#settings .card-title', 'Settings')
+ // Password policy
+ ->assertSeeIn('@form .row:nth-child(1) > label', 'Password Policy')
+ ->with('@form #password_policy', function (Browser $browser) {
+ $browser->assertElementsCount('li', 6)
+ ->assertSeeIn('li:nth-child(1) label', 'Minimum password length')
+ ->assertChecked('li:nth-child(1) input[type=checkbox]')
+ ->assertValue('li:nth-child(1) input[type=text]', '5')
+ ->assertSeeIn('li:nth-child(2) label', 'Maximum password length')
+ ->assertChecked('li:nth-child(2) input[type=checkbox]')
+ ->assertValue('li:nth-child(2) input[type=text]', '100')
+ ->assertSeeIn('li:nth-child(3) label', 'Password contains a lower-case character')
+ ->assertChecked('li:nth-child(3) input[type=checkbox]')
+ ->assertMissing('li:nth-child(3) input[type=text]')
+ ->assertSeeIn('li:nth-child(4) label', 'Password contains an upper-case character')
+ ->assertNotChecked('li:nth-child(4) input[type=checkbox]')
+ ->assertMissing('li:nth-child(4) input[type=text]')
+ ->assertSeeIn('li:nth-child(5) label', 'Password contains a digit')
+ ->assertNotChecked('li:nth-child(5) input[type=checkbox]')
+ ->assertMissing('li:nth-child(5) input[type=text]')
+ ->assertSeeIn('li:nth-child(6) label', 'Password contains a special character')
+ ->assertNotChecked('li:nth-child(6) input[type=checkbox]')
+ ->assertMissing('li:nth-child(6) input[type=text]')
+ // Change the policy
+ ->type('li:nth-child(1) input[type=text]', '11')
+ ->type('li:nth-child(2) input[type=text]', '120')
+ ->click('li:nth-child(3) input[type=checkbox]')
+ ->click('li:nth-child(4) input[type=checkbox]');
+ })
+ ->click('button[type=submit]')
+ ->assertToast(Toast::TYPE_SUCCESS, 'User settings updated successfully.');
+ });
+
+ $this->assertSame('min:11,max:120,upper', $john->getSetting('password_policy'));
+ }
+}
diff --git a/src/tests/Browser/SignupTest.php b/src/tests/Browser/SignupTest.php
--- a/src/tests/Browser/SignupTest.php
+++ b/src/tests/Browser/SignupTest.php
@@ -305,7 +305,7 @@
->assertMissing('#signup_first_name')
->assertVisible('#signup_login')
->assertVisible('#signup_password')
- ->assertVisible('#signup_confirm')
+ ->assertVisible('#signup_password_confirmation')
->assertVisible('select#signup_domain')
->assertElementsCount('select#signup_domain option', $domains_count, false)
->assertText('select#signup_domain option:nth-child(1)', $domains[0])
@@ -319,7 +319,14 @@
->assertSelected('select#signup_domain', \config('app.domain'))
->assertValue('#signup_login', '')
->assertValue('#signup_password', '')
- ->assertValue('#signup_confirm', '');
+ ->assertValue('#signup_password_confirmation', '')
+ ->with('#signup_password_policy', function (Browser $browser) {
+ $browser->assertElementsCount('li', 2)
+ ->assertMissing('li:first-child svg.text-success')
+ ->assertSeeIn('li:first-child small', "Minimum password length: 6 characters")
+ ->assertMissing('li:last-child svg.text-success')
+ ->assertSeeIn('li:last-child small', "Maximum password length: 255 characters");
+ });
// TODO: Test domain selector
});
@@ -351,12 +358,16 @@
$step->assertFocused('#signup_login')
->type('#signup_login', '*')
->type('#signup_password', '12345678')
- ->type('#signup_confirm', '123456789')
+ ->type('#signup_password_confirmation', '123456789')
+ ->with('#signup_password_policy', function (Browser $browser) {
+ $browser->waitFor('li:first-child svg.text-success')
+ ->waitFor('li:last-child svg.text-success');
+ })
->click('[type=submit]')
->waitFor('#signup_login.is-invalid')
->assertVisible('#signup_domain + .invalid-feedback')
->assertVisible('#signup_password.is-invalid')
- ->assertVisible('#signup_password + .invalid-feedback')
+ ->assertVisible('#signup_password_input .invalid-feedback')
->assertFocused('#signup_login')
->assertToast(Toast::TYPE_ERROR, 'Form validation error');
});
@@ -366,7 +377,7 @@
$step->type('#signup_login', 'SignupTestDusk')
->click('[type=submit]')
->waitFor('#signup_password.is-invalid')
- ->assertVisible('#signup_password + .invalid-feedback')
+ ->assertVisible('#signup_password_input .invalid-feedback')
->assertMissing('#signup_login.is-invalid')
->assertMissing('#signup_domain + .invalid-feedback')
->assertFocused('#signup_password')
@@ -375,7 +386,7 @@
// Submit valid data
$browser->with('@step3', function ($step) {
- $step->type('#signup_confirm', '12345678');
+ $step->type('#signup_password_confirmation', '12345678');
$step->click('[type=submit]');
});
@@ -433,7 +444,7 @@
$browser->whenAvailable('@step3', function ($step) {
$step->assertVisible('#signup_login')
->assertVisible('#signup_password')
- ->assertVisible('#signup_confirm')
+ ->assertVisible('#signup_password_confirmation')
->assertVisible('input#signup_domain')
->assertVisible('[type=button]')
->assertVisible('[type=submit]')
@@ -441,7 +452,7 @@
->assertValue('input#signup_domain', '')
->assertValue('#signup_login', '')
->assertValue('#signup_password', '')
- ->assertValue('#signup_confirm', '');
+ ->assertValue('#signup_password_confirmation', '');
});
// Submit invalid login and password data
@@ -450,12 +461,12 @@
->type('#signup_login', '*')
->type('#signup_domain', 'test.com')
->type('#signup_password', '12345678')
- ->type('#signup_confirm', '123456789')
+ ->type('#signup_password_confirmation', '123456789')
->click('[type=submit]')
->waitFor('#signup_login.is-invalid')
->assertVisible('#signup_domain + .invalid-feedback')
->assertVisible('#signup_password.is-invalid')
- ->assertVisible('#signup_password + .invalid-feedback')
+ ->assertVisible('#signup_password_input .invalid-feedback')
->assertFocused('#signup_login')
->assertToast(Toast::TYPE_ERROR, 'Form validation error');
});
@@ -465,12 +476,12 @@
$step->type('#signup_login', 'admin')
->type('#signup_domain', 'aaa')
->type('#signup_password', '12345678')
- ->type('#signup_confirm', '12345678')
+ ->type('#signup_password_confirmation', '12345678')
->click('[type=submit]')
->waitUntilMissing('#signup_login.is-invalid')
->waitFor('#signup_domain.is-invalid + .invalid-feedback')
->assertMissing('#signup_password.is-invalid')
- ->assertMissing('#signup_password + .invalid-feedback')
+ ->assertMissing('#signup_password_input .invalid-feedback')
->assertFocused('#signup_domain')
->assertToast(Toast::TYPE_ERROR, 'Form validation error');
});
@@ -533,7 +544,7 @@
->type('#signup_voucher', 'TESTXX')
->type('#signup_login', 'signuptestdusk')
->type('#signup_password', '123456789')
- ->type('#signup_confirm', '123456789')
+ ->type('#signup_password_confirmation', '123456789')
->click('[type=submit]')
->waitFor('#signup_voucher.is-invalid')
->assertVisible('#signup_voucher + .invalid-feedback')
@@ -585,7 +596,7 @@
->assertVisible('#signup_first_name')
->assertVisible('#signup_login')
->assertVisible('#signup_password')
- ->assertVisible('#signup_confirm')
+ ->assertVisible('#signup_password_confirmation')
->assertVisible('select#signup_domain')
->assertElementsCount('select#signup_domain option', $domains_count, false)
->assertVisible('[type=submit]')
@@ -597,22 +608,22 @@
->assertValue('#signup_last_name', '')
->assertValue('#signup_login', '')
->assertValue('#signup_password', '')
- ->assertValue('#signup_confirm', '');
+ ->assertValue('#signup_password_confirmation', '');
// Submit invalid data
$step->type('#signup_login', '*')
->type('#signup_password', '12345678')
- ->type('#signup_confirm', '123456789')
+ ->type('#signup_password_confirmation', '123456789')
->click('[type=submit]')
->waitFor('#signup_login.is-invalid')
->assertVisible('#signup_domain + .invalid-feedback')
->assertVisible('#signup_password.is-invalid')
- ->assertVisible('#signup_password + .invalid-feedback')
+ ->assertVisible('#signup_password_input .invalid-feedback')
->assertFocused('#signup_login')
->assertToast(Toast::TYPE_ERROR, 'Form validation error');
// Submit valid data
- $step->type('#signup_confirm', '12345678')
+ $step->type('#signup_password_confirmation', '12345678')
->type('#signup_login', 'signuptestdusk')
->type('#signup_first_name', 'First')
->type('#signup_last_name', 'Last')
diff --git a/src/tests/Browser/UserProfileTest.php b/src/tests/Browser/UserProfileTest.php
--- a/src/tests/Browser/UserProfileTest.php
+++ b/src/tests/Browser/UserProfileTest.php
@@ -62,6 +62,9 @@
*/
public function testProfile(): void
{
+ $user = $this->getTestUser('john@kolab.org');
+ $user->setSetting('password_policy', 'min:10,upper,digit');
+
$this->browse(function (Browser $browser) {
$browser->visit(new Home())
->submitLogon('john@kolab.org', 'simple123', true)
@@ -95,8 +98,26 @@
->assertValue('div.row:nth-child(9) input#password_confirmation', '')
->assertAttribute('#password', 'placeholder', 'Password')
->assertAttribute('#password_confirmation', 'placeholder', 'Confirm Password')
+ ->whenAvailable('#password_policy', function (Browser $browser) {
+ $browser->assertElementsCount('li', 3)
+ ->assertMissing('li:nth-child(1) svg.text-success')
+ ->assertSeeIn('li:nth-child(1) small', "Minimum password length: 10 characters")
+ ->assertMissing('li:nth-child(2) svg.text-success')
+ ->assertSeeIn('li:nth-child(2) small', "Password contains an upper-case character")
+ ->assertMissing('li:nth-child(3) svg.text-success')
+ ->assertSeeIn('li:nth-child(3) small', "Password contains a digit");
+ })
->assertSeeIn('button[type=submit]', 'Submit');
+ // Test password policy checking
+ $browser->type('#password', '1A')
+ ->whenAvailable('#password_policy', function (Browser $browser) {
+ $browser->waitFor('li:nth-child(2) svg.text-success')
+ ->waitFor('li:nth-child(3) svg.text-success')
+ ->assertMissing('li:nth-child(1) svg.text-success');
+ })
+ ->vueClear('#password');
+
// Test form error handling
$browser->type('#phone', 'aaaaaa')
->type('#external_email', 'bbbbb')
@@ -132,6 +153,9 @@
*/
public function testProfileNonController(): void
{
+ $user = $this->getTestUser('john@kolab.org');
+ $user->setSetting('password_policy', 'min:10,upper,digit');
+
// Test acting as non-controller
$this->browse(function (Browser $browser) {
$browser->visit('/logout')
@@ -145,6 +169,16 @@
->whenAvailable('@form', function (Browser $browser) {
// TODO: decide on what fields the non-controller user should be able
// to see/change
+ })
+ // Check that the account policy is used
+ ->whenAvailable('#password_policy', function (Browser $browser) {
+ $browser->assertElementsCount('li', 3)
+ ->assertMissing('li:nth-child(1) svg.text-success')
+ ->assertSeeIn('li:nth-child(1) small', "Minimum password length: 10 characters")
+ ->assertMissing('li:nth-child(2) svg.text-success')
+ ->assertSeeIn('li:nth-child(2) small', "Password contains an upper-case character")
+ ->assertMissing('li:nth-child(3) svg.text-success')
+ ->assertSeeIn('li:nth-child(3) small', "Password contains a digit");
});
// Test that /profile/delete page is not accessible
diff --git a/src/tests/Browser/UsersTest.php b/src/tests/Browser/UsersTest.php
--- a/src/tests/Browser/UsersTest.php
+++ b/src/tests/Browser/UsersTest.php
@@ -97,6 +97,7 @@
$jack = $this->getTestUser('jack@kolab.org');
$john->verificationcodes()->delete();
$jack->verificationcodes()->delete();
+ $john->setSetting('password_policy', 'min:10,upper,digit');
// Test that the page requires authentication
$browser->visit('/user/' . $john->id)
@@ -144,8 +145,17 @@
->assertSeeIn('#user-info .card-title', 'User account')
->with('@general', function (Browser $browser) {
// Test error handling (password)
- $browser->type('#password', 'aaaaaa')
+ $browser->type('#password', 'aaaaaA')
->vueClear('#password_confirmation')
+ ->whenAvailable('#password_policy', function (Browser $browser) {
+ $browser->assertElementsCount('li', 3)
+ ->assertMissing('li:nth-child(1) svg.text-success')
+ ->assertSeeIn('li:nth-child(1) small', "Minimum password length: 10 characters")
+ ->waitFor('li:nth-child(2) svg.text-success')
+ ->assertSeeIn('li:nth-child(2) small', "Password contains an upper-case character")
+ ->assertMissing('li:nth-child(3) svg.text-success')
+ ->assertSeeIn('li:nth-child(3) small', "Password contains a digit");
+ })
->click('button[type=submit]')
->waitFor('#password_confirmation + .invalid-feedback')
->assertSeeIn(
@@ -163,6 +173,7 @@
->with(new ListInput('#aliases'), function (Browser $browser) {
$browser->addListEntry('invalid address');
})
+ ->scrollTo('button[type=submit]')->pause(500)
->click('button[type=submit]')
->assertToast(Toast::TYPE_ERROR, 'Form validation error');
@@ -389,6 +400,9 @@
*/
public function testNewUser(): void
{
+ $john = $this->getTestUser('john@kolab.org');
+ $john->setSetting('password_policy', null);
+
$this->browse(function (Browser $browser) {
$browser->visit(new UserList())
->assertSeeIn('button.create-user', 'Create user')
@@ -819,6 +833,7 @@
'Access to calendaring resources'
)
// Shared folders SKU
+ ->scrollTo('tbody tr:nth-child(10)')->pause(500)
->assertSeeIn('tbody tr:nth-child(10) td.name', 'Shared folders')
->assertSeeIn('tr:nth-child(10) td.price', '0,00 CHF/month')
->assertNotChecked('tbody tr:nth-child(10) td.selection input')
@@ -858,7 +873,7 @@
->on(new UserInfo())
->waitFor('#sku-input-beta')
->click('#sku-input-beta')
- ->scrollTo('@general button[type=submit]')
+ ->scrollTo('@general button[type=submit]')->pause(500)
->click('@general button[type=submit]')
->assertToast(Toast::TYPE_SUCCESS, 'User data updated successfully.');
diff --git a/src/tests/Feature/Controller/PasswordPolicyTest.php b/src/tests/Feature/Controller/PasswordPolicyTest.php
new file mode 100644
--- /dev/null
+++ b/src/tests/Feature/Controller/PasswordPolicyTest.php
@@ -0,0 +1,133 @@
+<?php
+
+namespace Tests\Feature\Controller;
+
+use Tests\TestCase;
+
+class PasswordPolicyTest extends TestCase
+{
+ /**
+ * Test password policy check
+ */
+ public function testCheck(): void
+ {
+ $jack = $this->getTestUser('jack@kolab.org');
+ $john = $this->getTestUser('john@kolab.org');
+ $john->setSetting('password_policy', 'min:8,max:100,upper,digit');
+
+ // Empty password
+ $post = ['user' => $john->id];
+ $response = $this->post('/api/auth/password-policy/check', $post);
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertCount(3, $json);
+ $this->assertSame('error', $json['status']);
+ $this->assertSame(4, $json['count']);
+ $this->assertFalse($json['list'][0]['status']);
+ $this->assertSame('min', $json['list'][0]['label']);
+ $this->assertFalse($json['list'][1]['status']);
+ $this->assertSame('max', $json['list'][1]['label']);
+ $this->assertFalse($json['list'][2]['status']);
+ $this->assertSame('upper', $json['list'][2]['label']);
+ $this->assertFalse($json['list'][3]['status']);
+ $this->assertSame('digit', $json['list'][3]['label']);
+
+ // Test acting as Jack, password non-compliant
+ $post = ['password' => '9999999', 'user' => $jack->id];
+ $response = $this->post('/api/auth/password-policy/check', $post);
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertCount(3, $json);
+ $this->assertSame('error', $json['status']);
+ $this->assertSame(4, $json['count']);
+ $this->assertFalse($json['list'][0]['status']); // min
+ $this->assertTrue($json['list'][1]['status']); // max
+ $this->assertFalse($json['list'][2]['status']); // upper
+ $this->assertTrue($json['list'][3]['status']); // digit
+
+ // Test with no user context, expect use of the default policy
+ $post = ['password' => '9'];
+ $response = $this->post('/api/auth/password-policy/check', $post);
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertCount(3, $json);
+ $this->assertSame('error', $json['status']);
+ $this->assertSame(2, $json['count']);
+ $this->assertFalse($json['list'][0]['status']);
+ $this->assertSame('min', $json['list'][0]['label']);
+ $this->assertTrue($json['list'][1]['status']);
+ $this->assertSame('max', $json['list'][1]['label']);
+ }
+
+ /**
+ * Test password-policy listing
+ */
+ public function testIndex(): void
+ {
+ // Unauth access not allowed
+ $response = $this->get('/api/v4/password-policy');
+ $response->assertStatus(401);
+
+ $jack = $this->getTestUser('jack@kolab.org');
+ $john = $this->getTestUser('john@kolab.org');
+ $john->setSetting('password_policy', 'min:8,max:255,special');
+
+ // Get available policy rules
+ $response = $this->actingAs($john)->get('/api/v4/password-policy');
+ $json = $response->json();
+
+ $response->assertStatus(200);
+
+ $this->assertCount(2, $json);
+ $this->assertSame(6, $json['count']);
+ $this->assertCount(6, $json['list']);
+ $this->assertSame('Minimum password length: 8 characters', $json['list'][0]['name']);
+ $this->assertSame('min', $json['list'][0]['label']);
+ $this->assertSame('8', $json['list'][0]['param']);
+ $this->assertSame(true, $json['list'][0]['enabled']);
+ $this->assertSame('Maximum password length: 255 characters', $json['list'][1]['name']);
+ $this->assertSame('max', $json['list'][1]['label']);
+ $this->assertSame('255', $json['list'][1]['param']);
+ $this->assertSame(true, $json['list'][1]['enabled']);
+ $this->assertSame('lower', $json['list'][2]['label']);
+ $this->assertSame(false, $json['list'][2]['enabled']);
+ $this->assertSame('upper', $json['list'][3]['label']);
+ $this->assertSame(false, $json['list'][3]['enabled']);
+ $this->assertSame('digit', $json['list'][4]['label']);
+ $this->assertSame(false, $json['list'][4]['enabled']);
+ $this->assertSame('special', $json['list'][5]['label']);
+ $this->assertSame(true, $json['list'][5]['enabled']);
+
+ // Test acting as Jack
+ $response = $this->actingAs($jack)->get('/api/v4/password-policy');
+ $json = $response->json();
+
+ $response->assertStatus(200);
+
+ $this->assertCount(2, $json);
+ $this->assertSame(6, $json['count']);
+ $this->assertCount(6, $json['list']);
+ $this->assertSame('Minimum password length: 8 characters', $json['list'][0]['name']);
+ $this->assertSame('min', $json['list'][0]['label']);
+ $this->assertSame('8', $json['list'][0]['param']);
+ $this->assertSame(true, $json['list'][0]['enabled']);
+ $this->assertSame('Maximum password length: 255 characters', $json['list'][1]['name']);
+ $this->assertSame('max', $json['list'][1]['label']);
+ $this->assertSame('255', $json['list'][1]['param']);
+ $this->assertSame(true, $json['list'][1]['enabled']);
+ $this->assertSame('lower', $json['list'][2]['label']);
+ $this->assertSame(false, $json['list'][2]['enabled']);
+ $this->assertSame('upper', $json['list'][3]['label']);
+ $this->assertSame(false, $json['list'][3]['enabled']);
+ $this->assertSame('digit', $json['list'][4]['label']);
+ $this->assertSame(false, $json['list'][4]['enabled']);
+ $this->assertSame('special', $json['list'][5]['label']);
+ $this->assertSame(true, $json['list'][5]['enabled']);
+ }
+}
diff --git a/src/tests/Feature/Controller/PasswordResetTest.php b/src/tests/Feature/Controller/PasswordResetTest.php
--- a/src/tests/Feature/Controller/PasswordResetTest.php
+++ b/src/tests/Feature/Controller/PasswordResetTest.php
@@ -207,8 +207,9 @@
$json = $response->json();
$response->assertStatus(200);
- $this->assertCount(1, $json);
+ $this->assertCount(2, $json);
$this->assertSame('success', $json['status']);
+ $this->assertSame($user->id, $json['userId']);
}
/**
@@ -222,12 +223,14 @@
$data = [];
$response = $this->post('/api/auth/password-reset', $data);
+ $response->assertStatus(422);
+
$json = $response->json();
- $response->assertStatus(422);
$this->assertSame('error', $json['status']);
- $this->assertCount(1, $json['errors']);
- $this->assertArrayHasKey('password', $json['errors']);
+ $this->assertCount(2, $json['errors']);
+ $this->assertArrayHasKey('code', $json['errors']);
+ $this->assertArrayHasKey('short_code', $json['errors']);
$user = $this->getTestUser('passwordresettest@' . \config('app.domain'));
$code = new VerificationCode(['mode' => 'password-reset']);
@@ -236,12 +239,14 @@
// Data with existing code but missing password
$data = [
'code' => $code->code,
+ 'short_code' => $code->short_code,
];
$response = $this->post('/api/auth/password-reset', $data);
+ $response->assertStatus(422);
+
$json = $response->json();
- $response->assertStatus(422);
$this->assertSame('error', $json['status']);
$this->assertCount(1, $json['errors']);
$this->assertArrayHasKey('password', $json['errors']);
@@ -262,6 +267,22 @@
$this->assertCount(1, $json['errors']);
$this->assertArrayHasKey('password', $json['errors']);
+ // Data with existing code but password too short
+ $data = [
+ 'code' => $code->code,
+ 'short_code' => $code->short_code,
+ 'password' => 'pas',
+ 'password_confirmation' => 'pas',
+ ];
+
+ $response = $this->post('/api/auth/password-reset', $data);
+ $json = $response->json();
+
+ $response->assertStatus(422);
+ $this->assertSame('error', $json['status']);
+ $this->assertCount(1, $json['errors']);
+ $this->assertArrayHasKey('password', $json['errors']);
+
// Data with invalid short code
$data = [
'code' => $code->code,
@@ -294,8 +315,8 @@
Queue::assertNothingPushed();
$data = [
- 'password' => 'test',
- 'password_confirmation' => 'test',
+ 'password' => 'testtest',
+ 'password_confirmation' => 'testtest',
'code' => $code->code,
'short_code' => $code->short_code,
];
diff --git a/src/tests/Feature/Controller/SignupTest.php b/src/tests/Feature/Controller/SignupTest.php
--- a/src/tests/Feature/Controller/SignupTest.php
+++ b/src/tests/Feature/Controller/SignupTest.php
@@ -453,7 +453,7 @@
$domain = $this->getPublicDomain();
- // Login too short
+ // Login too short, password too short
$data = [
'login' => '1',
'domain' => $domain,
@@ -466,15 +466,16 @@
$response->assertStatus(422);
$this->assertSame('error', $json['status']);
- $this->assertCount(1, $json['errors']);
+ $this->assertCount(2, $json['errors']);
$this->assertArrayHasKey('login', $json['errors']);
+ $this->assertArrayHasKey('password', $json['errors']);
// Missing codes
$data = [
'login' => 'login-valid',
'domain' => $domain,
- 'password' => 'test',
- 'password_confirmation' => 'test',
+ 'password' => 'testtest',
+ 'password_confirmation' => 'testtest',
];
$response = $this->post('/api/auth/signup', $data);
@@ -490,8 +491,8 @@
$data = [
'login' => 'TestLogin',
'domain' => $domain,
- 'password' => 'test',
- 'password_confirmation' => 'test',
+ 'password' => 'testtest',
+ 'password_confirmation' => 'testtest',
'code' => $result['code'],
'short_code' => 'XXXX',
];
@@ -510,8 +511,8 @@
$data = [
'login' => 'TestLogin',
'domain' => $domain,
- 'password' => 'test',
- 'password_confirmation' => 'test',
+ 'password' => 'testtest',
+ 'password_confirmation' => 'testtest',
'code' => $result['code'],
'short_code' => $code->short_code,
'voucher' => 'XXX',
@@ -529,8 +530,8 @@
$data = [
'login' => 'żżżżżż',
'domain' => $domain,
- 'password' => 'test',
- 'password_confirmation' => 'test',
+ 'password' => 'testtest',
+ 'password_confirmation' => 'testtest',
'code' => $result['code'],
'short_code' => $code->short_code,
];
@@ -559,8 +560,8 @@
$data = [
'login' => 'SignupLogin',
'domain' => $domain,
- 'password' => 'test',
- 'password_confirmation' => 'test',
+ 'password' => 'testtest',
+ 'password_confirmation' => 'testtest',
'code' => $code->code,
'short_code' => $code->short_code,
'voucher' => 'TEST',
@@ -672,8 +673,8 @@
$data = [
'login' => $login,
'domain' => $domain,
- 'password' => 'test',
- 'password_confirmation' => 'test',
+ 'password' => 'testtest',
+ 'password_confirmation' => 'testtest',
'code' => $code->code,
'short_code' => $code->short_code,
];
@@ -745,8 +746,8 @@
'last_name' => 'User',
'login' => 'test-inv',
'domain' => 'kolabnow.com',
- 'password' => 'test',
- 'password_confirmation' => 'test',
+ 'password' => 'testtest',
+ 'password_confirmation' => 'testtest',
];
// Test invalid invitation identifier
diff --git a/src/tests/Feature/Controller/UsersTest.php b/src/tests/Feature/Controller/UsersTest.php
--- a/src/tests/Feature/Controller/UsersTest.php
+++ b/src/tests/Feature/Controller/UsersTest.php
@@ -521,6 +521,7 @@
$john = $this->getTestUser('john@kolab.org');
$john->setSetting('greylist_enabled', null);
+ $john->setSetting('password_policy', null);
// Test unknown user id
$post = ['greylist_enabled' => 1];
@@ -555,7 +556,7 @@
$this->assertNull($john->fresh()->getSetting('greylist_enabled'));
// Test some valid data
- $post = ['greylist_enabled' => 1];
+ $post = ['greylist_enabled' => 1, 'password_policy' => 'min:10,max:255,upper,lower,digit,special'];
$response = $this->actingAs($john)->post("/api/v4/users/{$john->id}/config", $post);
$response->assertStatus(200);
@@ -566,10 +567,12 @@
$this->assertSame('User settings updated successfully.', $json['message']);
$this->assertSame('true', $john->fresh()->getSetting('greylist_enabled'));
+ $this->assertSame('min:10,max:255,upper,lower,digit,special', $john->fresh()->getSetting('password_policy'));
- // Test some valid data
- $post = ['greylist_enabled' => 0];
- $response = $this->actingAs($john)->post("/api/v4/users/{$john->id}/config", $post);
+ // Test some valid data, acting as another account controller
+ $ned = $this->getTestUser('ned@kolab.org');
+ $post = ['greylist_enabled' => 0, 'password_policy' => 'min:10,max:255,upper'];
+ $response = $this->actingAs($ned)->post("/api/v4/users/{$john->id}/config", $post);
$response->assertStatus(200);
$json = $response->json();
@@ -579,6 +582,7 @@
$this->assertSame('User settings updated successfully.', $json['message']);
$this->assertSame('false', $john->fresh()->getSetting('greylist_enabled'));
+ $this->assertSame('min:10,max:255,upper', $john->fresh()->getSetting('password_policy'));
}
/**
@@ -590,6 +594,7 @@
$jack = $this->getTestUser('jack@kolab.org');
$john = $this->getTestUser('john@kolab.org');
+ $john->setSetting('password_policy', 'min:8,max:100,digit');
$deleted_priv = $this->getTestUser('deleted@kolab.org');
$deleted_priv->delete();
@@ -629,8 +634,8 @@
// Test existing user email
$post = [
- 'password' => 'simple',
- 'password_confirmation' => 'simple',
+ 'password' => 'simple123',
+ 'password_confirmation' => 'simple123',
'first_name' => 'John2',
'last_name' => 'Doe2',
'email' => 'jack.daniels@kolab.org',
@@ -649,8 +654,8 @@
$package_domain = \App\Package::withEnvTenantContext()->where('title', 'domain-hosting')->first();
$post = [
- 'password' => 'simple',
- 'password_confirmation' => 'simple',
+ 'password' => 'simple123',
+ 'password_confirmation' => 'simple123',
'first_name' => 'John2',
'last_name' => 'Doe2',
'email' => 'john2.doe2@kolab.org',
@@ -679,8 +684,33 @@
$this->assertSame("Invalid package selected.", $json['errors']['package']);
$this->assertCount(2, $json);
- // Test full and valid data
+ // Test password policy checking
$post['package'] = $package_kolab->id;
+ $post['password'] = 'password';
+ $response = $this->actingAs($john)->post("/api/v4/users", $post);
+ $json = $response->json();
+
+ $response->assertStatus(422);
+
+ $this->assertSame('error', $json['status']);
+ $this->assertSame("The password confirmation does not match.", $json['errors']['password'][0]);
+ $this->assertSame("Specified password does not comply with the policy.", $json['errors']['password'][1]);
+ $this->assertCount(2, $json);
+
+ // Test password confirmation
+ $post['password_confirmation'] = 'password';
+ $response = $this->actingAs($john)->post("/api/v4/users", $post);
+ $json = $response->json();
+
+ $response->assertStatus(422);
+
+ $this->assertSame('error', $json['status']);
+ $this->assertSame("Specified password does not comply with the policy.", $json['errors']['password'][0]);
+ $this->assertCount(2, $json);
+
+ // Test full and valid data
+ $post['password'] = 'password123';
+ $post['password_confirmation'] = 'password123';
$response = $this->actingAs($john)->post("/api/v4/users", $post);
$json = $response->json();
@@ -772,6 +802,7 @@
public function testUpdate(): void
{
$userA = $this->getTestUser('UsersControllerTest1@userscontroller.com');
+ $userA->setSetting('password_policy', 'min:8,digit');
$jack = $this->getTestUser('jack@kolab.org');
$john = $this->getTestUser('john@kolab.org');
$ned = $this->getTestUser('ned@kolab.org');
@@ -800,7 +831,7 @@
$this->assertCount(3, $json);
// Test some invalid data
- $post = ['password' => '12345678', 'currency' => 'invalid'];
+ $post = ['password' => '1234567', 'currency' => 'invalid'];
$response = $this->actingAs($userA)->put("/api/v4/users/{$userA->id}", $post);
$response->assertStatus(422);
@@ -808,13 +839,14 @@
$this->assertSame('error', $json['status']);
$this->assertCount(2, $json);
- $this->assertSame('The password confirmation does not match.', $json['errors']['password'][0]);
- $this->assertSame('The currency must be 3 characters.', $json['errors']['currency'][0]);
+ $this->assertSame("The password confirmation does not match.", $json['errors']['password'][0]);
+ $this->assertSame("Specified password does not comply with the policy.", $json['errors']['password'][1]);
+ $this->assertSame("The currency must be 3 characters.", $json['errors']['currency'][0]);
// Test full profile update including password
$post = [
- 'password' => 'simple',
- 'password_confirmation' => 'simple',
+ 'password' => 'simple123',
+ 'password_confirmation' => 'simple123',
'first_name' => 'John2',
'last_name' => 'Doe2',
'organization' => 'TestOrg',
@@ -1175,6 +1207,7 @@
$this->assertTrue($result['statusInfo']['enableDomains']);
$this->assertTrue($result['statusInfo']['enableWallets']);
$this->assertTrue($result['statusInfo']['enableUsers']);
+ $this->assertTrue($result['statusInfo']['enableSettings']);
// Ned is John's wallet controller
$ned = $this->getTestUser('ned@kolab.org');
@@ -1196,6 +1229,7 @@
$this->assertTrue($result['statusInfo']['enableDomains']);
$this->assertTrue($result['statusInfo']['enableWallets']);
$this->assertTrue($result['statusInfo']['enableUsers']);
+ $this->assertTrue($result['statusInfo']['enableSettings']);
// Test discount in a response
$discount = Discount::where('code', 'TEST')->first();
@@ -1224,6 +1258,7 @@
$this->assertFalse($result['statusInfo']['enableDomains']);
$this->assertFalse($result['statusInfo']['enableWallets']);
$this->assertFalse($result['statusInfo']['enableUsers']);
+ $this->assertFalse($result['statusInfo']['enableSettings']);
}
/**
diff --git a/src/tests/Feature/DomainTest.php b/src/tests/Feature/DomainTest.php
--- a/src/tests/Feature/DomainTest.php
+++ b/src/tests/Feature/DomainTest.php
@@ -348,4 +348,24 @@
}
);
}
+
+ /**
+ * Tests for Domain::walletOwner() (from EntitleableTrait)
+ */
+ public function testWalletOwner(): void
+ {
+ $domain = $this->getTestDomain('kolab.org');
+ $john = $this->getTestUser('john@kolab.org');
+
+ $this->assertSame($john->id, $domain->walletOwner()->id);
+
+ // A domain without an owner
+ $domain = $this->getTestDomain('gmail.com', [
+ 'status' => Domain::STATUS_NEW | Domain::STATUS_SUSPENDED
+ | Domain::STATUS_LDAP_READY | Domain::STATUS_CONFIRMED,
+ 'type' => Domain::TYPE_PUBLIC,
+ ]);
+
+ $this->assertSame(null, $domain->walletOwner());
+ }
}
diff --git a/src/tests/Feature/UserTest.php b/src/tests/Feature/UserTest.php
--- a/src/tests/Feature/UserTest.php
+++ b/src/tests/Feature/UserTest.php
@@ -383,18 +383,58 @@
{
$john = $this->getTestUser('john@kolab.org');
$john->setSetting('greylist_enabled', null);
+ $john->setSetting('password_policy', null);
- $this->assertSame(['greylist_enabled' => true], $john->getConfig());
+ // Greylist_enabled
+ $this->assertSame(true, $john->getConfig()['greylist_enabled']);
$result = $john->setConfig(['greylist_enabled' => false, 'unknown' => false]);
- $this->assertSame(['greylist_enabled' => false], $john->getConfig());
+ $this->assertSame(['unknown' => "The requested configuration parameter is not supported."], $result);
+ $this->assertSame(false, $john->getConfig()['greylist_enabled']);
$this->assertSame('false', $john->getSetting('greylist_enabled'));
$result = $john->setConfig(['greylist_enabled' => true]);
- $this->assertSame(['greylist_enabled' => true], $john->getConfig());
+ $this->assertSame([], $result);
+ $this->assertSame(true, $john->getConfig()['greylist_enabled']);
$this->assertSame('true', $john->getSetting('greylist_enabled'));
+
+ // Password_policy
+ $result = $john->setConfig(['password_policy' => true]);
+
+ $this->assertSame(['password_policy' => "Specified password policy is invalid."], $result);
+ $this->assertSame(null, $john->getConfig()['password_policy']);
+ $this->assertSame(null, $john->getSetting('password_policy'));
+
+ $result = $john->setConfig(['password_policy' => 'min:-1']);
+
+ $this->assertSame(['password_policy' => "Specified password policy is invalid."], $result);
+
+ $result = $john->setConfig(['password_policy' => 'min:-1']);
+
+ $this->assertSame(['password_policy' => "Specified password policy is invalid."], $result);
+
+ $result = $john->setConfig(['password_policy' => 'min:10,unknown']);
+
+ $this->assertSame(['password_policy' => "Specified password policy is invalid."], $result);
+
+ \config(['app.password_policy' => 'min:5,max:100']);
+ $result = $john->setConfig(['password_policy' => 'min:4,max:255']);
+
+ $this->assertSame(['password_policy' => "Minimum password length cannot be less than 5."], $result);
+
+ \config(['app.password_policy' => 'min:5,max:100']);
+ $result = $john->setConfig(['password_policy' => 'min:10,max:255']);
+
+ $this->assertSame(['password_policy' => "Maximum password length cannot be more than 100."], $result);
+
+ \config(['app.password_policy' => 'min:5,max:255']);
+ $result = $john->setConfig(['password_policy' => 'min:10,max:255']);
+
+ $this->assertSame([], $result);
+ $this->assertSame('min:10,max:255', $john->getConfig()['password_policy']);
+ $this->assertSame('min:10,max:255', $john->getSetting('password_policy'));
}
/**
@@ -1177,10 +1217,37 @@
}
/**
+ * Tests for User::walletOwner() (from EntitleableTrait)
+ */
+ public function testWalletOwner(): void
+ {
+ $jack = $this->getTestUser('jack@kolab.org');
+ $john = $this->getTestUser('john@kolab.org');
+ $ned = $this->getTestUser('ned@kolab.org');
+
+ $this->assertSame($john->id, $john->walletOwner()->id);
+ $this->assertSame($john->id, $jack->walletOwner()->id);
+ $this->assertSame($john->id, $ned->walletOwner()->id);
+
+ // User with no entitlements
+ $user = $this->getTestUser('UserAccountA@UserAccount.com');
+ $this->assertSame($user->id, $user->walletOwner()->id);
+ }
+
+ /**
* Tests for User::wallets()
*/
public function testWallets(): void
{
- $this->markTestIncomplete();
+ $john = $this->getTestUser('john@kolab.org');
+ $ned = $this->getTestUser('ned@kolab.org');
+
+ $this->assertSame(1, $john->wallets()->count());
+ $this->assertCount(1, $john->wallets);
+ $this->assertInstanceOf(\App\Wallet::class, $john->wallets->first());
+
+ $this->assertSame(1, $ned->wallets()->count());
+ $this->assertCount(1, $ned->wallets);
+ $this->assertInstanceOf(\App\Wallet::class, $ned->wallets->first());
}
}
diff --git a/src/tests/Unit/Rules/PasswordTest.php b/src/tests/Unit/Rules/PasswordTest.php
new file mode 100644
--- /dev/null
+++ b/src/tests/Unit/Rules/PasswordTest.php
@@ -0,0 +1,182 @@
+<?php
+
+namespace Tests\Unit\Rules;
+
+use App\Rules\Password;
+use Illuminate\Support\Facades\Validator;
+use Tests\TestCase;
+
+class PasswordTest extends TestCase
+{
+ /**
+ * Test password validation
+ */
+ public function testValidator(): void
+ {
+ $error = "Specified password does not comply with the policy.";
+
+ \config(['app.password_policy' => 'min:5']);
+ $this->assertSame($error, $this->validate('abcd'));
+ $this->assertSame(null, $this->validate('abcde'));
+
+ \config(['app.password_policy' => 'min:5,max:10']);
+ $this->assertSame($error, $this->validate('12345678901'));
+ $this->assertSame(null, $this->validate('1234567890'));
+
+ \config(['app.password_policy' => 'min:5,lower']);
+ $this->assertSame($error, $this->validate('12345'));
+ $this->assertSame($error, $this->validate('AAAAA'));
+ $this->assertSame(null, $this->validate('12345a'));
+
+ \config(['app.password_policy' => 'upper']);
+ $this->assertSame($error, $this->validate('5'));
+ $this->assertSame($error, $this->validate('a'));
+ $this->assertSame(null, $this->validate('A'));
+
+ \config(['app.password_policy' => 'digit']);
+ $this->assertSame($error, $this->validate('a'));
+ $this->assertSame($error, $this->validate('A'));
+ $this->assertSame(null, $this->validate('5'));
+
+ \config(['app.password_policy' => 'special']);
+ $this->assertSame($error, $this->validate('a'));
+ $this->assertSame($error, $this->validate('5'));
+ $this->assertSame(null, $this->validate('*'));
+ $this->assertSame(null, $this->validate('-'));
+
+ // Test with an account policy
+ $user = $this->getTestUser('john@kolab.org');
+ $user->setSetting('password_policy', 'min:10,upper');
+
+ $this->assertSame($error, $this->validate('aaa', $user));
+ $this->assertSame($error, $this->validate('1234567890', $user));
+ $this->assertSame(null, $this->validate('1234567890A', $user));
+ }
+
+ /**
+ * Test check() method
+ */
+ public function testCheck(): void
+ {
+ $pass = new Password();
+
+ \config(['app.password_policy' => 'min:5,max:10,upper,lower,digit']);
+ $result = $pass->check('abcd');
+
+ $this->assertCount(5, $result);
+ $this->assertSame('min', $result['min']['label']);
+ $this->assertSame('Minimum password length: 5 characters', $result['min']['name']);
+ $this->assertSame('5', $result['min']['param']);
+ $this->assertSame(true, $result['min']['enabled']);
+ $this->assertSame(false, $result['min']['status']);
+
+ $this->assertSame('max', $result['max']['label']);
+ $this->assertSame('Maximum password length: 10 characters', $result['max']['name']);
+ $this->assertSame('10', $result['max']['param']);
+ $this->assertSame(true, $result['max']['enabled']);
+ $this->assertSame(true, $result['max']['status']);
+
+ $this->assertSame('upper', $result['upper']['label']);
+ $this->assertSame('Password contains an upper-case character', $result['upper']['name']);
+ $this->assertSame(null, $result['upper']['param']);
+ $this->assertSame(true, $result['upper']['enabled']);
+ $this->assertSame(false, $result['upper']['status']);
+
+ $this->assertSame('lower', $result['lower']['label']);
+ $this->assertSame('Password contains a lower-case character', $result['lower']['name']);
+ $this->assertSame(null, $result['lower']['param']);
+ $this->assertSame(true, $result['lower']['enabled']);
+ $this->assertSame(true, $result['lower']['status']);
+
+ $this->assertSame('digit', $result['digit']['label']);
+ $this->assertSame('Password contains a digit', $result['digit']['name']);
+ $this->assertSame(null, $result['digit']['param']);
+ $this->assertSame(true, $result['digit']['enabled']);
+ $this->assertSame(false, $result['digit']['status']);
+ }
+
+ /**
+ * Test rules() method
+ */
+ public function testRules(): void
+ {
+ $user = $this->getTestUser('john@kolab.org');
+ $user->setSetting('password_policy', 'min:10,upper');
+
+ $pass = new Password($user);
+
+ \config(['app.password_policy' => 'min:5,max:10,digit']);
+
+ $result = $pass->rules();
+
+ $this->assertCount(2, $result);
+ $this->assertSame('min', $result['min']['label']);
+ $this->assertSame('Minimum password length: 10 characters', $result['min']['name']);
+ $this->assertSame('10', $result['min']['param']);
+ $this->assertSame(true, $result['min']['enabled']);
+
+ $this->assertSame('upper', $result['upper']['label']);
+ $this->assertSame('Password contains an upper-case character', $result['upper']['name']);
+ $this->assertSame(null, $result['upper']['param']);
+ $this->assertSame(true, $result['upper']['enabled']);
+
+ // Expect to see all supported policy rules
+ $result = $pass->rules(true);
+
+ $this->assertCount(6, $result);
+ $this->assertSame('min', $result['min']['label']);
+ $this->assertSame('Minimum password length: 10 characters', $result['min']['name']);
+ $this->assertSame('10', $result['min']['param']);
+ $this->assertSame(true, $result['min']['enabled']);
+
+ $this->assertSame('max', $result['max']['label']);
+ $this->assertSame('Maximum password length: 255 characters', $result['max']['name']);
+ $this->assertSame('255', $result['max']['param']);
+ $this->assertSame(false, $result['max']['enabled']);
+
+ $this->assertSame('upper', $result['upper']['label']);
+ $this->assertSame('Password contains an upper-case character', $result['upper']['name']);
+ $this->assertSame(null, $result['upper']['param']);
+ $this->assertSame(true, $result['upper']['enabled']);
+
+ $this->assertSame('lower', $result['lower']['label']);
+ $this->assertSame('Password contains a lower-case character', $result['lower']['name']);
+ $this->assertSame(null, $result['lower']['param']);
+ $this->assertSame(false, $result['lower']['enabled']);
+
+ $this->assertSame('digit', $result['digit']['label']);
+ $this->assertSame('Password contains a digit', $result['digit']['name']);
+ $this->assertSame(null, $result['digit']['param']);
+ $this->assertSame(false, $result['digit']['enabled']);
+
+ $this->assertSame('special', $result['special']['label']);
+ $this->assertSame('Password contains a special character', $result['special']['name']);
+ $this->assertSame(null, $result['digit']['param']);
+ $this->assertSame(false, $result['digit']['enabled']);
+ }
+
+ /**
+ * Validates the password using Laravel Validator API
+ *
+ * @param string $password The password to validate
+ * @param ?\App\User $user The account owner
+ *
+ * @return ?string Validation error message on error, NULL otherwise
+ */
+ private function validate($password, $user = null): ?string
+ {
+ // Instead of doing direct tests, we use validator to make sure
+ // it works with the framework api
+
+ $v = Validator::make(
+ ['password' => $password],
+ ['password' => new Password($user)]
+ );
+
+ if ($v->fails()) {
+ return $v->errors()->toArray()['password'][0];
+ }
+
+ return null;
+ }
+}
File Metadata
Details
Attached
Mime Type
text/plain
Expires
Mon, Mar 30, 2:45 AM (5 d, 12 h ago)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
18776403
Default Alt Text
D3319.1774838719.diff (102 KB)
Attached To
Mode
D3319: Password Policy
Attached
Detach File
Event Timeline