Page MenuHomePhorge

D5403.1775215380.diff
No OneTemporary

Authored By
Unknown
Size
47 KB
Referenced Files
None
Subscribers
None

D5403.1775215380.diff

diff --git a/src/app/AuthAttempt.php b/src/app/AuthAttempt.php
--- a/src/app/AuthAttempt.php
+++ b/src/app/AuthAttempt.php
@@ -19,6 +19,7 @@
public const REASON_NONE = '';
public const REASON_PASSWORD = 'password';
+ public const REASON_PASSWORD_EXPIRED = 'password-expired';
public const REASON_GEOLOCATION = 'geolocation';
public const REASON_NOTFOUND = 'notfound';
public const REASON_2FA = '2fa';
diff --git a/src/app/Console/Commands/PasswordRetentionCommand.php b/src/app/Console/Commands/PasswordRetentionCommand.php
--- a/src/app/Console/Commands/PasswordRetentionCommand.php
+++ b/src/app/Console/Commands/PasswordRetentionCommand.php
@@ -6,6 +6,7 @@
use App\Jobs\Mail\PasswordRetentionJob;
use App\User;
use Carbon\Carbon;
+use Illuminate\Database\Query\Builder;
use Illuminate\Support\Facades\DB;
class PasswordRetentionCommand extends Command
@@ -22,7 +23,7 @@
*
* @var string
*/
- protected $description = 'Notifies users about expected expiration of their password.';
+ protected $description = 'Handles password expiration and sends related email notification.';
/**
* Execute the console command.
@@ -45,14 +46,19 @@
. " where users.id = user_settings.user_id and user_settings.key = 'password_update'"
. ") as password_update")
)
+ // skip users with expired password
+ ->whereNotExists(function (Builder $query) {
+ $query->select(DB::raw(1))
+ ->from('user_settings')
+ ->where('key', 'password_expired')
+ ->whereColumn('user_settings.user_id', 'users.id');
+ })
+ // Skip incomplete or suspended users
+ ->where('status', '&', User::STATUS_IMAP_READY)
+ ->whereNot('status', '&', User::STATUS_SUSPENDED)
->get()
->each(static function ($user) use ($account) {
/** @var User $user */
- // Skip incomplete or suspended users
- if (!$user->isImapReady() || $user->isSuspended()) {
- return;
- }
-
// If the password was never updated use the user creation time
if (!empty($user->password_update)) {
$lastUpdate = new Carbon($user->password_update);
@@ -64,8 +70,10 @@
$nextUpdate = $lastUpdate->copy()->addMonthsWithoutOverflow((int) $account->max_age);
$diff = Carbon::now()->diffInDays($nextUpdate, false);
- // The password already expired, do nothing
+ // The password already expired
if ($diff <= 0) {
+ // TODO: Invalidate all existing "session" tokens if possible?
+ $user->setSetting('password_expired', Carbon::now()->toDateTimeString());
return;
}
diff --git a/src/app/Http/Controllers/API/AuthController.php b/src/app/Http/Controllers/API/AuthController.php
--- a/src/app/Http/Controllers/API/AuthController.php
+++ b/src/app/Http/Controllers/API/AuthController.php
@@ -3,6 +3,7 @@
namespace App\Http\Controllers\API;
use App\Auth\OAuth;
+use App\AuthAttempt;
use App\Http\Controllers\Controller;
use App\User;
use App\Utils;
@@ -226,8 +227,21 @@
return response()->json(['status' => 'error', 'errors' => $errors], 422);
}
- \Log::warning("Failed to request a token: " . (string) $tokenResponse);
- return response()->json(['status' => 'error', 'message' => self::trans('auth.failed')], 401);
+ $response = ['status' => 'error', 'message' => self::trans('auth.failed')];
+
+ if (isset($data->error) && $data->error == AuthAttempt::REASON_PASSWORD_EXPIRED) {
+ $response['message'] = $data->error_description;
+ $response['password_expired'] = true;
+
+ if ($user) {
+ // At this point we know the password is correct, but expired.
+ // So, it should be safe to send the user ID back. It will be used
+ // for the new password policy checks.
+ $response['id'] = $user->id;
+ }
+ }
+
+ return response()->json($response, 401);
}
$response = [];
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
@@ -9,6 +9,7 @@
use App\VerificationCode;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
+use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
@@ -112,7 +113,7 @@
}
/**
- * Password change
+ * Password reset (using an email verification code)
*
* @param Request $request HTTP request
*
@@ -137,14 +138,49 @@
return response()->json(['status' => 'error', 'errors' => $v->errors()], 422);
}
- // Change the user password
- $user->setPasswordAttribute($request->password);
- $user->save();
+ return self::changeUserPassword($user, $request);
+ }
+
+ /**
+ * Expired password change (using user credentials)
+ *
+ * @param Request $request HTTP request
+ *
+ * @return JsonResponse JSON response
+ */
+ public function resetExpired(Request $request)
+ {
+ $user = User::where('email', $request->email)->first();
+
+ if (!$user || $user->role == User::ROLE_SERVICE) {
+ $auth_error = true;
+ }
+
+ // Validate the current password
+ if (empty($auth_error) && $user->validatePassword($request->password, true) !== true) {
+ $auth_error = true;
+ }
- // Remove the verification code
- $request->code->delete();
+ if (!empty($auth_error)) {
+ $errors = ['password' => self::trans('auth.failed')];
+ return response()->json(['status' => 'error', 'errors' => $errors], 422);
+ }
- return AuthController::logonResponse($user, $request->password);
+ // Validate the passwords
+ $v = Validator::make(
+ $request->all(),
+ [
+ 'new_password' => ['required', 'confirmed', new Password($user->walletOwner())],
+ ]
+ );
+
+ if ($v->fails()) {
+ return response()->json(['status' => 'error', 'errors' => $v->errors()], 422);
+ }
+
+ $request->password = $request->new_password;
+
+ return self::changeUserPassword($user, $request);
}
/**
@@ -209,4 +245,39 @@
'message' => self::trans('app.password-reset-code-delete-success'),
]);
}
+
+ /**
+ * Change the user password and log-in the user
+ */
+ private static function changeUserPassword(User $user, Request $request)
+ {
+ DB::beginTransaction();
+
+ // Change the user password
+ $user->password = $request->password;
+ $user->save();
+
+ // Note: If logonResponse() would not use a HTTP request, this whole code
+ // could be possibly a bit simpler (no need for a DB transaction).
+ $response = AuthController::logonResponse($user, $request->password, $request->secondfactor);
+
+ if ($response->status() == 200) {
+ // Remove the verification code
+ if ($request->code instanceof VerificationCode) {
+ $request->code->delete();
+ }
+
+ DB::commit();
+
+ // Add confirmation message to the 'success' response
+ $data = $response->getData(true);
+ $data['message'] = self::trans('app.password-reset-success');
+ $response->setData($data);
+ } else {
+ // If authentication failed (2FA or geo-lock), revert the password change
+ DB::rollBack();
+ }
+
+ return $response;
+ }
}
diff --git a/src/app/Http/Controllers/API/V4/PolicyController.php b/src/app/Http/Controllers/API/V4/PolicyController.php
--- a/src/app/Http/Controllers/API/V4/PolicyController.php
+++ b/src/app/Http/Controllers/API/V4/PolicyController.php
@@ -24,7 +24,6 @@
public function checkPassword(Request $request)
{
$userId = $request->input('user');
-
$user = !empty($userId) ? User::find($userId) : null;
// Check the password
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
@@ -460,10 +460,8 @@
$response['isLocked'] = (!$user->isActive() && ($plan = $wallet->plan()) && $plan->mode == Plan::MODE_MANDATE);
// Settings
- $response['settings'] = [];
- foreach ($user->settings()->whereIn('key', self::USER_SETTINGS)->get() as $item) {
- $response['settings'][$item->key] = $item->value;
- }
+ $keys = array_merge(self::USER_SETTINGS, ['password_expired']);
+ $response['settings'] = $user->settings()->whereIn('key', $keys)->pluck('value', 'key')->all();
// Status info
$response['statusInfo'] = self::statusInfo($user);
diff --git a/src/app/Observers/UserObserver.php b/src/app/Observers/UserObserver.php
--- a/src/app/Observers/UserObserver.php
+++ b/src/app/Observers/UserObserver.php
@@ -243,9 +243,13 @@
});
}
- // Save the old password in the password history
+ // Password change
$oldPassword = $user->getOriginal('password');
if ($oldPassword && $user->password != $oldPassword) {
+ // Reset the password expiration settings
+ $user->removeSettingsQuietly(['password_expired', 'password_expiration_warning']);
+
+ // Save the old password in the password history
self::saveOldPassword($user, $oldPassword);
}
}
@@ -311,13 +315,10 @@
*/
private static function saveOldPassword(User $user, string $password): void
{
- // Remember the timestamp of the last password change and unset the last warning date
- $user->setSettings([
- 'password_expiration_warning' => null,
- // Note: We could get this from user_passwords table, but only if the policy
- // enables storing of old passwords there.
- 'password_update' => now()->format('Y-m-d H:i:s'),
- ]);
+ // Remember the timestamp of the last password change
+ // Note: We could get this from user_passwords table, but only if the policy
+ // enables storing of old passwords there.
+ $user->setSetting('password_update', now()->format('Y-m-d H:i:s'));
Password::saveHash($user, $password);
}
diff --git a/src/app/Traits/SettingsTrait.php b/src/app/Traits/SettingsTrait.php
--- a/src/app/Traits/SettingsTrait.php
+++ b/src/app/Traits/SettingsTrait.php
@@ -68,6 +68,16 @@
$this->setSetting($key, null);
}
+ /**
+ * Remove settings without invoking events.
+ *
+ * @param array $keys Setting names
+ */
+ public function removeSettingsQuietly(array $keys): void
+ {
+ $this->settings()->whereIn('key', $keys)->delete();
+ }
+
/**
* Create or update a setting.
*
@@ -129,9 +139,12 @@
$setting->delete();
}
} else {
- $this->settings()->updateOrCreate(
- ['key' => $key],
- ['value' => $value]
+ // Note: upsert() is a single query (INSERT ... ON DUPLICATE KEY UPDATE),
+ // updateOrCreate() is a few queries (BEGIN + INSERT [+ UPDATE] + COMMIT).
+ $this->settings()->upsert(
+ ['key' => $key, 'value' => $value],
+ uniqueBy: ['user_id', 'key', 'value'],
+ update: ['key', 'value']
);
}
}
diff --git a/src/app/User.php b/src/app/User.php
--- a/src/app/User.php
+++ b/src/app/User.php
@@ -781,9 +781,12 @@
/**
* Validate the user credentials
*
- * @param string $password the password in plain text
+ * @param string $password The password in plain text
+ * @param bool $allow_expired Allow expired password
+ *
+ * @return true|string True on success, Error reason otherwise
*/
- public function validatePassword(string $password): bool
+ public function validatePassword(string $password, bool $allow_expired = false)
{
if (!empty($this->password)) {
$authenticated = Hash::check($password, $this->password);
@@ -794,11 +797,18 @@
$authenticated = false;
}
- if ($authenticated) {
+ // Note: We intentionally check if password is expired when we know it's valid
+ if ($authenticated === true && !$allow_expired && $this->getSetting('password_expired')) {
+ $authenticated = AuthAttempt::REASON_PASSWORD_EXPIRED;
+ }
+
+ if ($authenticated === true) {
if (empty($this->password) || empty($this->password_ldap)) {
$this->password = $password;
$this->save();
}
+ } elseif ($authenticated === false) {
+ $authenticated = AuthAttempt::REASON_PASSWORD;
}
return $authenticated;
@@ -860,8 +870,8 @@
}
}
- if (!$user->validatePassword($password)) {
- $error = AuthAttempt::REASON_PASSWORD;
+ if (($vresult = $user->validatePassword($password)) !== true) {
+ $error = $vresult;
}
}
}
@@ -934,17 +944,21 @@
// If we didn't do this, we couldn't pair backup devices.
$verifyMFA = false;
}
+
$result = self::findAndAuthenticate($username, $password, null, $verifyMFA);
if (isset($result['reason'])) {
- if ($result['reason'] == AuthAttempt::REASON_2FA_GENERIC) {
- // This results in a json response of {'error': 'secondfactor', 'error_description': '$errorMessage'}
- throw new OAuthServerException($result['errorMessage'], 6, 'secondfactor', 401);
+ switch ($result['reason']) {
+ case AuthAttempt::REASON_2FA_GENERIC:
+ $errorType = 'secondfactor'; // TODO: Can we just use $result['reason'] instead?
+ // no break
+ case AuthAttempt::REASON_PASSWORD_EXPIRED:
+ // This results in a json response of {'error': $errorType, 'error_description': $errorMessage}
+ throw new OAuthServerException($result['errorMessage'], 6, $errorType ?? $result['reason'], 401);
+ default:
+ // TODO: Display specific error message if 2FA via Companion App was expected?
+ throw OAuthServerException::invalidCredentials();
}
-
- // TODO: Display specific error message if 2FA via Companion App was expected?
-
- throw OAuthServerException::invalidCredentials();
}
return $result['user'];
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
@@ -156,6 +156,10 @@
routerState.afterLogin = null
+ if (response.message) {
+ this.$toast.success(response.message)
+ }
+
// Refresh the token before it expires
let timeout = response.expires_in || 0
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
@@ -165,6 +165,7 @@
'wallet-update-success' => 'User wallet updated successfully.',
'password-reset-code-delete-success' => 'Password reset code deleted successfully.',
+ 'password-reset-success' => 'Password updated 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',
diff --git a/src/resources/lang/en/auth.php b/src/resources/lang/en/auth.php
--- a/src/resources/lang/en/auth.php
+++ b/src/resources/lang/en/auth.php
@@ -22,6 +22,7 @@
'claim.auth.token' => "Have read and write access to all your data",
'error.password' => "Invalid password",
+ 'error.password-expired' => "Expired password",
'error.invalidrequest' => "Invalid authorization request.",
'error.geolocation' => "Country code mismatch",
'error.notfound' => "User not found",
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
@@ -367,7 +367,11 @@
],
'password' => [
+ 'current' => "Current password",
+ 'expired-text' => "Your password expired. To log into your account you have to set a new password.",
+ 'expired-on' => "Password expired on {date}",
'link-invalid' => "The password reset code is expired or invalid.",
+ 'new' => "New password",
'reset' => "Password Reset",
'reset-step1' => "Enter your email address to reset your password.",
'reset-step1-hint' => "You may need to check your spam folder or unblock {email}.",
diff --git a/src/resources/vue/Admin/User.vue b/src/resources/vue/Admin/User.vue
--- a/src/resources/vue/Admin/User.vue
+++ b/src/resources/vue/Admin/User.vue
@@ -27,6 +27,7 @@
<span class="form-control-plaintext" id="status">
<span :class="$root.statusClass(user)">{{ $root.statusText(user) }}</span>
<span v-if="user.isRestricted" class="badge bg-primary rounded-pill ms-1">{{ $t('status.restricted') }}</span>
+ <small v-if="user.settings.password_expired" class="d-block text-danger">{{ $t('password.expired-on', { date: user.settings.password_expired }) }}</small>
</span>
</div>
</div>
@@ -388,6 +389,7 @@
aliases: [],
config: {},
wallet: {},
+ settings: {},
skus: {},
}
}
diff --git a/src/resources/vue/Login.vue b/src/resources/vue/Login.vue
--- a/src/resources/vue/Login.vue
+++ b/src/resources/vue/Login.vue
@@ -4,19 +4,19 @@
<div class="card-body p-4">
<h1 class="card-title text-center mb-3">{{ $t('login.header') }}</h1>
<div class="card-text m-2 mb-0">
- <form class="form-signin" @submit.prevent="submitLogin">
+ <form class="form-signin" @submit.prevent="submit">
<div class="row mb-3">
- <label for="inputEmail" class="visually-hidden">{{ $t('form.email') }}</label>
+ <label for="email" class="visually-hidden">{{ $t('form.email') }}</label>
<div class="input-group">
<span class="input-group-text"><svg-icon icon="user"></svg-icon></span>
- <input type="email" id="inputEmail" class="form-control" :placeholder="$t('form.email')" required autofocus v-model="email">
+ <input type="email" id="email" class="form-control" :placeholder="$t('form.email')" required autofocus v-model="email">
</div>
</div>
- <div class="row mb-4">
- <label for="inputPassword" class="visually-hidden">{{ $t('form.password') }}</label>
+ <div class="row mb-3">
+ <label for="password" class="visually-hidden">{{ $t('form.password') }}</label>
<div class="input-group">
<span class="input-group-text"><svg-icon icon="lock"></svg-icon></span>
- <input type="password" id="inputPassword" class="form-control" :placeholder="$t('form.password')" required v-model="password">
+ <input type="password" id="password" class="form-control" :placeholder="$t('form.password')" required v-model="password">
</div>
</div>
<div class="row mb-3" v-if="$root.isUser">
@@ -27,6 +27,12 @@
</div>
<small class="text-muted mt-2">{{ $t('login.2fa_desc') }}</small>
</div>
+ <p class="alert alert-danger d-flex align-items-center" role="alert" v-if="mode == 'expired-password'">
+ <svg-icon icon="circle-exclamation" class="fs-4 flex-shrink-0 me-2"></svg-icon> {{ $t('password.expired-text') }}
+ </p>
+ <div class="mb-4" v-if="mode == 'expired-password'">
+ <password-input class="mb-3" v-model="pass" :user="userId" placeholder="password.new" prefix="new_"></password-input>
+ </div>
<div class="text-center">
<btn class="btn-primary" type="submit" icon="right-to-bracket" :is-loading="loading">
{{ $t(loading ? 'login.signing_in' : 'login.sign_in') }}
@@ -44,42 +50,67 @@
</template>
<script>
+ import PasswordInput from './Widgets/PasswordInput'
+
import { library } from '@fortawesome/fontawesome-svg-core'
library.add(
+ require('@fortawesome/free-solid-svg-icons/faCircleExclamation').definition,
require('@fortawesome/free-solid-svg-icons/faKey').definition,
require('@fortawesome/free-solid-svg-icons/faLock').definition,
require('@fortawesome/free-solid-svg-icons/faRightToBracket').definition,
)
export default {
+ components: {
+ PasswordInput
+ },
props: {
dashboard: { type: Boolean, default: true }
},
data() {
return {
+ current: '',
email: '',
+ mode: 'login',
+ pass: {},
password: '',
secondfactor: '',
+ userId: '',
webmailURL: window.config['app.webmail_url'],
loading: false
}
},
methods: {
- submitLogin() {
+ submit() {
this.$root.clearFormValidation($('form.form-signin'))
+ let url = 'api/auth/login'
const post = this.$root.pick(this, ['email', 'password', 'secondfactor'])
this.loading = true
- axios.post('/api/auth/login', post)
+ if (this.mode == 'expired-password') {
+ url = '/api/auth/password-reset-expired'
+ post.new_password = this.pass.password
+ post.new_password_confirmation = this.pass.password_confirmation
+ }
+
+ axios.post(url, post)
.then(response => {
// login user and redirect to dashboard
this.$root.loginUser(response.data, this.dashboard)
this.$emit('success')
})
- .catch(() => {})
+ .catch(error => {
+ if (error.status == 401 && error.response.data.password_expired) {
+ this.userId = error.response.data.id
+ this.mode = 'expired-password'
+ // We don't want email change at this point as it would not match
+ // userId, which is used for new password validity/policy checks
+ $('#email').prop('disabled', true)
+ }
+ })
.finally(() => {
this.loading = false
})
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
@@ -58,7 +58,10 @@
</div>
</div>
<div class="row mb-3">
- <label for="password" class="col-sm-4 col-form-label">{{ $t('form.password') }}</label>
+ <label for="password" class="col-sm-4 col-form-label">
+ {{ $t('form.password') }}
+ <btn v-if="user.password_expired" class="btn-link btn-lg text-warning p-0" icon="circle-exclamation" v-tooltip="$t('password.expired-on', { date: user.password_expired})"></btn>
+ </label>
<div class="col-sm-8">
<div v-if="!isSelf" class="btn-group w-100" role="group">
<input type="checkbox" id="pass-mode-input" value="input" class="btn-check" @change="setPasswordMode" :checked="passwordMode == 'input'">
diff --git a/src/resources/vue/Widgets/PasswordInput.vue b/src/resources/vue/Widgets/PasswordInput.vue
--- a/src/resources/vue/Widgets/PasswordInput.vue
+++ b/src/resources/vue/Widgets/PasswordInput.vue
@@ -5,7 +5,7 @@
class="form-control"
autocomplete="new-password"
:id="prefix + 'password'"
- :placeholder="$t('form.password')"
+ :placeholder="$t(placeholder ? placeholder : 'form.password')"
v-model="password"
@input="onInput"
>
@@ -33,6 +33,8 @@
props: {
focus: { type: Boolean, default: false },
value: { type: Object, default: () => {} },
+ placeholder: { type: String, default: '' },
+ prefix: { type: String, default: '' },
user: { type: [String, Number], default: '' }
},
data() {
@@ -40,7 +42,6 @@
password: '',
password_confirmation: '',
policy: [],
- prefix: ''
}
},
mounted() {
@@ -48,7 +49,9 @@
const input = $('#password')[0]
- this.prefix = $(input.form).data('validation-prefix') || ''
+ if (this.prefix == '') {
+ this.prefix = $(input.form).data('validation-prefix') || ''
+ }
$(input.form).on('reset', () => { this.checkPolicy('') })
diff --git a/src/routes/api.php b/src/routes/api.php
--- a/src/routes/api.php
+++ b/src/routes/api.php
@@ -21,6 +21,7 @@
Route::post('password-reset/init', [API\PasswordResetController::class, 'init']);
Route::post('password-reset/verify', [API\PasswordResetController::class, 'verify']);
Route::post('password-reset', [API\PasswordResetController::class, 'reset']);
+ Route::post('password-reset-expired', [API\PasswordResetController::class, 'resetExpired']);
if (\config('app.with_signup')) {
Route::get('signup/domains', [API\SignupController::class, 'domains']);
diff --git a/src/tests/Browser/Admin/UserTest.php b/src/tests/Browser/Admin/UserTest.php
--- a/src/tests/Browser/Admin/UserTest.php
+++ b/src/tests/Browser/Admin/UserTest.php
@@ -28,6 +28,7 @@
$john->setSettings([
'phone' => '+48123123123',
'external_email' => 'john.doe.external@gmail.com',
+ 'password_expired' => '2020-01-01 10:10:10',
]);
if ($john->isSuspended()) {
User::where('email', $john->email)->update(['status' => $john->status - User::STATUS_SUSPENDED]);
@@ -49,6 +50,7 @@
$john->setSettings([
'phone' => null,
'external_email' => 'john.doe.external@gmail.com',
+ 'password_expired' => '2020-01-01 10:10:10',
]);
if ($john->isSuspended()) {
User::where('email', $john->email)->update(['status' => $john->status - User::STATUS_SUSPENDED]);
@@ -263,6 +265,7 @@
$group = $this->getTestGroup('group-test@kolab.org', ['name' => 'Test Group']);
$group->assignToWallet($john->wallets->first());
$john->setSetting('greylist_enabled', null);
+ $john->setSetting('password_expired', '2020-01-01 10:10:10');
// Click the managed-by link on Jack's page
$browser->click('@user-info #manager a')
@@ -278,6 +281,7 @@
->assertSeeIn('.row:nth-child(1) #userid', "{$john->id} ({$john->created_at})")
->assertSeeIn('.row:nth-child(2) label', 'Status')
->assertSeeIn('.row:nth-child(2) #status span.text-success', 'Active')
+ ->assertSeeIn('.row:nth-child(2) #status small.text-danger', 'Password expired on 2020-01-01 10:10:10')
->assertSeeIn('.row:nth-child(3) label', 'First Name')
->assertSeeIn('.row:nth-child(3) #first_name', 'John')
->assertSeeIn('.row:nth-child(4) label', 'Last Name')
diff --git a/src/tests/Browser/LogonTest.php b/src/tests/Browser/LogonTest.php
--- a/src/tests/Browser/LogonTest.php
+++ b/src/tests/Browser/LogonTest.php
@@ -12,6 +12,20 @@
class LogonTest extends TestCaseDusk
{
+ protected function setUp(): void
+ {
+ parent::setUp();
+
+ $this->deleteTestUser('test@logon.test');
+ }
+
+ protected function tearDown(): void
+ {
+ $this->deleteTestUser('test@logon.test');
+
+ parent::tearDown();
+ }
+
/**
* Test menu on logon page
*/
@@ -177,7 +191,8 @@
public function testLogout(): void
{
$this->browse(static function (Browser $browser) {
- $browser->on(new Dashboard());
+ $browser->on(new Dashboard())
+ ->clearToasts();
// Click the Logout button
$browser->within(new Menu(), static function ($browser) {
@@ -198,6 +213,38 @@
});
}
+ /**
+ * Test logon with an expired password (with password update)
+ */
+ public function testLogonExpiredPassword(): void
+ {
+ $this->browse(function (Browser $browser) {
+ $cur_pass = 'simple123';
+ $new_pass = 'ABC123456789';
+ $user = $this->getTestUser('test@logon.test', ['password' => $cur_pass]);
+ $user->setSetting('password_expired', now()->toDateTimeString());
+
+ $browser->visit(new Home())
+ ->submitLogon($user->email, $cur_pass, false)
+ ->waitFor('@new-password-input')
+ ->assertVisible('p.alert')
+ ->waitFor('#new_password_policy > li:first-child > span.text-secondary')
+ ->type('@new-password-input', $new_pass)
+ ->type('@new-password-confirmation-input', $new_pass)
+ ->waitFor('#new_password_policy > li:first-child > svg.text-success')
+ ->click('@logon-button')
+ ->assertToast(Toast::TYPE_SUCCESS, 'Password updated successfully.')
+ ->on(new Dashboard())
+ ->assertUser($user->email);
+
+ $user->refresh();
+ $this->assertTrue($user->validatePassword($new_pass));
+ });
+
+ // TODO: Test error handling
+ // TODO: Test 2FA handling
+ }
+
/**
* Logout by URL test
*/
diff --git a/src/tests/Browser/Pages/Home.php b/src/tests/Browser/Pages/Home.php
--- a/src/tests/Browser/Pages/Home.php
+++ b/src/tests/Browser/Pages/Home.php
@@ -39,10 +39,13 @@
{
return [
'@app' => '#app',
- '@email-input' => '#inputEmail',
- '@password-input' => '#inputPassword',
+ '@email-input' => '#email',
+ '@password-input' => '#password',
'@second-factor-input' => '#secondfactor',
+ '@logon-form' => '#logon-form',
'@logon-button' => '#logon-form button.btn-primary',
+ '@new-password-input' => '#new_password',
+ '@new-password-confirmation-input' => '#new_password_confirmation',
];
}
@@ -63,6 +66,9 @@
$config = []
) {
$browser->clearToasts()
+ ->assertMissing('@new-password-input')
+ ->assertMissing('@new-password-confirmation-input')
+ ->assertMissing('@logon-form p.alert')
->type('@email-input', $username)
->type('@password-input', $password);
@@ -77,7 +83,7 @@
);
}
- $browser->press('form button');
+ $browser->press('@logon-button');
if ($wait_for_dashboard) {
$browser->waitForLocation('/dashboard');
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
@@ -38,6 +38,7 @@
'itip_config' => null,
'externalsender_config' => null,
'greylist_policy' => null,
+ 'password_expired' => null,
];
protected function setUp(): void
@@ -127,6 +128,7 @@
->assertValue('@input', '');
})
->assertSeeIn('div.row:nth-child(4) label', 'Password')
+ ->assertMissing('div.row:nth-child(4) label > button') // expired password tooltip button
->assertValue('div.row:nth-child(4) input#password', '')
->assertValue('div.row:nth-child(4) input#password_confirmation', '')
->assertAttribute('#password', 'placeholder', 'Password')
@@ -182,6 +184,18 @@
$alias = $john->aliases()->where('alias', 'john.test@kolab.org')->first();
$this->assertTrue(!empty($alias));
+ // Test expired password indication
+ $john->setSetting('password_expired', '2020-01-01 10:10:10');
+
+ $browser->refresh()
+ ->on(new UserInfo())
+ ->with('@general', static function (Browser $browser) {
+ $browser->assertTip(
+ 'div.row:nth-child(4) label > button',
+ 'Password expired on 2020-01-01 10:10:10'
+ );
+ });
+
// Test subscriptions
$browser->with('@general', static function (Browser $browser) {
$browser->assertSeeIn('div.row:nth-child(5) label', 'Subscriptions')
@@ -593,7 +607,7 @@
->click('button[type=submit]');
})
->assertToast(Toast::TYPE_SUCCESS, 'User created successfully.')
- // check redirection to users list
+ // check redirection to users list
->on(new UserList())
->whenAvailable('@table', static function (Browser $browser) {
$browser->assertElementsCount('tbody tr', 5)
diff --git a/src/tests/Feature/Console/PasswordRetentionTest.php b/src/tests/Feature/Console/PasswordRetentionTest.php
--- a/src/tests/Feature/Console/PasswordRetentionTest.php
+++ b/src/tests/Feature/Console/PasswordRetentionTest.php
@@ -73,17 +73,29 @@
$this->assertSame("", $output);
Queue::assertNothingPushed();
+ $this->assertNotNull($user->getSetting('password_expired'));
+ $this->assertNotNull($owner->getSetting('password_expired'));
// $user's password is about to expire in 14 days
$user->setSetting('password_update', now()->copy()->subMonthsWithoutOverflow(2)->addDays(14));
+
// $owner's password is about to expire in 7 days
$owner->created_at = now()->copy()->subMonthsWithoutOverflow(2)->addDays(7);
$owner->save();
+ // Test no warning for users with expired passwords
+ $code = \Artisan::call("password:retention");
+ $this->assertSame(0, $code);
+
+ Queue::assertNothingPushed();
+
+ $user->removeSetting('password_expired');
+ $owner->removeSetting('password_expired');
+
+ // Test notifications
$code = \Artisan::call("password:retention");
$output = trim(\Artisan::output());
$this->assertSame(0, $code);
- $this->assertSame("", $output);
Queue::assertPushed(PasswordRetentionJob::class, 2);
Queue::assertPushed(PasswordRetentionJob::class, static function ($job) use ($user) {
diff --git a/src/tests/Feature/Controller/AuthTest.php b/src/tests/Feature/Controller/AuthTest.php
--- a/src/tests/Feature/Controller/AuthTest.php
+++ b/src/tests/Feature/Controller/AuthTest.php
@@ -33,7 +33,10 @@
IP4Net::where('net_number', inet_pton('128.0.0.0'))->delete();
$user = $this->getTestUser('john@kolab.org');
- $user->setSetting('limit_geo', null);
+ $user->setSettings([
+ 'limit_geo' => null,
+ 'password_expired' => null,
+ ]);
}
protected function tearDown(): void
diff --git a/src/tests/Feature/Controller/NGINXTest.php b/src/tests/Feature/Controller/NGINXTest.php
--- a/src/tests/Feature/Controller/NGINXTest.php
+++ b/src/tests/Feature/Controller/NGINXTest.php
@@ -21,6 +21,7 @@
$john->setSettings([
'limit_geo' => null,
'guam_enabled' => null,
+ 'password_expired' => null,
]);
IP4Net::where('net_number', inet_pton('128.0.0.0'))->delete();
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
@@ -2,6 +2,7 @@
namespace Tests\Feature\Controller;
+use App\Auth\SecondFactor;
use App\IP4Net;
use App\Jobs\Mail\PasswordResetJob;
use App\Jobs\User\UpdateJob;
@@ -18,6 +19,11 @@
$this->deleteTestUser('passwordresettest@' . \config('app.domain'));
+ $user = $this->getTestUser('ned@kolab.org');
+ $user->removeSetting('password_expired');
+ $user->password = \config('app.passphrase');
+ $user->save();
+
IP4Net::where('net_number', inet_pton('128.0.0.0'))->delete();
}
@@ -25,6 +31,12 @@
{
$this->deleteTestUser('passwordresettest@' . \config('app.domain'));
+ $user = $this->getTestUser('ned@kolab.org');
+ $user->removeSetting('password_expired');
+ $user->password = \config('app.passphrase');
+ $user->save();
+ $user->verificationcodes()->delete();
+
IP4Net::where('net_number', inet_pton('128.0.0.0'))->delete();
parent::tearDown();
@@ -341,6 +353,7 @@
{
$user = $this->getTestUser('passwordresettest@' . \config('app.domain'));
$code = new VerificationCode(['mode' => 'password-reset']);
+ $user->verificationcodes()->delete();
$user->verificationcodes()->save($code);
Queue::fake();
@@ -376,12 +389,179 @@
}
);
+ $user->refresh();
+ $this->assertTrue($user->validatePassword('testtest'));
+
// Check if the code has been removed
- $this->assertNull(VerificationCode::find($code->code));
+ $this->assertCount(0, $user->verificationcodes()->get());
+
+ // Test 2FA handling
+ $user = $this->getTestUser('ned@kolab.org');
+ $code = new VerificationCode(['mode' => 'password-reset']);
+ $user->verificationcodes()->delete();
+ $user->verificationcodes()->save($code);
+ $user->removeSetting('password_expired');
+
+ $data = [
+ 'password' => 'ABC123456789',
+ 'password_confirmation' => 'ABC123456789',
+ 'code' => $code->code,
+ 'short_code' => $code->short_code,
+ ];
+
+ $response = $this->post('/api/auth/password-reset', $data);
+ $response->assertStatus(422);
+
+ $json = $response->json();
+
+ $this->assertSame('error', $json['status']);
+ $this->assertCount(1, $json['errors']);
+ $this->assertArrayHasKey('secondfactor', $json['errors']);
+
+ // Make sure password didn't change if 2FA wasn't provided
+ $user->refresh();
+ $this->assertTrue($user->validatePassword(\config('app.passphrase')));
+ $this->assertCount(1, $user->verificationcodes()->get());
+
+ $data['secondfactor'] = SecondFactor::code('ned@kolab.org');
+ $response = $this->post('/api/auth/password-reset', $data);
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertNotEmpty($json['access_token']);
+ $this->assertSame($user->email, $json['email']);
+ $this->assertSame($user->id, $json['id']);
+
+ $user->refresh();
+ $this->assertTrue($user->validatePassword('ABC123456789'));
+ $this->assertCount(0, $user->verificationcodes()->get());
+ }
+
+ /**
+ * Test password-reset-expired
+ */
+ public function testPasswordResetExpired()
+ {
+ $cur_pass = 'testtest';
+ $new_pass = 'Test123456789';
+ $user = $this->getTestUser('passwordresettest@' . \config('app.domain'), ['password' => $cur_pass]);
+ $user->setSetting('password_expired', now()->toDateTimeString());
+
+ $this->assertTrue($user->validatePassword($cur_pass, true));
+
+ // Empty data
+ $data = [];
+
+ $response = $this->post('/api/auth/password-reset-expired', $data);
+ $response->assertStatus(422);
+
+ $json = $response->json();
+
+ $this->assertSame('error', $json['status']);
+ $this->assertCount(1, $json['errors']);
+ $this->assertSame(['password' => 'Invalid username or password.'], $json['errors']);
+
+ // Data with invalid user email
+ $data = [
+ 'email' => 'test@unknown.com',
+ 'password' => $cur_pass,
+ 'new_password' => $new_pass,
+ 'new_password_confirmation' => $new_pass,
+ ];
+
+ $response = $this->post('/api/auth/password-reset-expired', $data);
+ $response->assertStatus(422);
- // TODO: Check password before and after (?)
+ $json = $response->json();
+
+ $this->assertSame('error', $json['status']);
+ $this->assertCount(1, $json['errors']);
+ $this->assertSame(['password' => 'Invalid username or password.'], $json['errors']);
+
+ // Data with invalid password
+ $data['email'] = $user->email;
+ $data['password'] = 'test';
+
+ $response = $this->post('/api/auth/password-reset-expired', $data);
+ $response->assertStatus(422);
+
+ $json = $response->json();
+
+ $this->assertSame('error', $json['status']);
+ $this->assertCount(1, $json['errors']);
+ $this->assertSame(['password' => 'Invalid username or password.'], $json['errors']);
+
+ // Data with a weak new password
+ $data['password'] = $cur_pass;
+ $data['new_password'] = '1';
+ $data['new_password_confirmation'] = '1';
+
+ $response = $this->post('/api/auth/password-reset-expired', $data);
+ $response->assertStatus(422);
+
+ $json = $response->json();
+
+ $this->assertSame('error', $json['status']);
+ $this->assertCount(1, $json['errors']);
+ $this->assertArrayHasKey('new_password', $json['errors']);
+
+ // Valid data
+ $data['new_password'] = $new_pass;
+ $data['new_password_confirmation'] = $new_pass;
+
+ $response = $this->post('/api/auth/password-reset-expired', $data);
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame('success', $json['status']);
+ $this->assertSame('bearer', $json['token_type']);
+ $this->assertTrue(!empty($json['expires_in']) && is_int($json['expires_in']) && $json['expires_in'] > 0);
+ $this->assertNotEmpty($json['access_token']);
+ $this->assertSame($user->email, $json['email']);
+ $this->assertSame($user->id, $json['id']);
+
+ $user->refresh();
+ $this->assertTrue($user->validatePassword($new_pass));
+
+ // Test 2FA handling
+ $user = $this->getTestUser('ned@kolab.org');
+ $user->setSetting('password_expired', now()->toDateTimeString());
+
+ $data = [
+ 'email' => $user->email,
+ 'password' => \config('app.passphrase'),
+ 'new_password' => $new_pass,
+ 'new_password_confirmation' => $new_pass,
+ ];
+
+ $response = $this->post('/api/auth/password-reset-expired', $data);
+ $response->assertStatus(422);
+
+ $json = $response->json();
+
+ $this->assertSame('error', $json['status']);
+ $this->assertCount(1, $json['errors']);
+ $this->assertArrayHasKey('secondfactor', $json['errors']);
+
+ // Make sure password didn't change if 2FA wasn't provided
+ $user->refresh();
+ $this->assertNotNull($user->getSetting('password_expired'));
+ $this->assertTrue($user->validatePassword(\config('app.passphrase'), true));
+
+ $data['secondfactor'] = SecondFactor::code('ned@kolab.org');
+ $response = $this->post('/api/auth/password-reset-expired', $data);
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertNotEmpty($json['access_token']);
+ $this->assertSame($user->email, $json['email']);
+ $this->assertSame($user->id, $json['id']);
- // TODO: Check if the access token works
+ $user->refresh();
+ $this->assertTrue($user->validatePassword($new_pass));
}
/**
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
@@ -3,6 +3,7 @@
namespace Tests\Feature;
use App\Auth\Utils as AuthUtils;
+use App\AuthAttempt;
use App\Delegation;
use App\Domain;
use App\Entitlement;
@@ -463,7 +464,10 @@
);
// Update the user, test the password change
- $user->setSetting('password_expiration_warning', '2020-10-10 10:10:10');
+ $user->setSettings([
+ 'password_expiration_warning' => '2020-10-10 10:10:10',
+ 'password_expired' => '2020-10-20 10:10:10',
+ ]);
$oldPassword = $user->password;
$user->password = 'test123';
$user->save();
@@ -471,6 +475,7 @@
$this->assertNotSame($oldPassword, $user->password);
$this->assertSame(0, $user->passwords()->count());
$this->assertNull($user->getSetting('password_expiration_warning'));
+ $this->assertNull($user->getSetting('password_expired'));
$this->assertMatchesRegularExpression(
'/^' . now()->format('Y-m-d') . ' [0-9]{2}:[0-9]{2}:[0-9]{2}$/',
$user->getSetting('password_update')
@@ -1729,7 +1734,7 @@
// Wrong password
$user->setRawAttributes(array_merge($attrs, ['password_ldap' => null]));
- $this->assertFalse($user->validatePassword('wrong'));
+ $this->assertSame(AuthAttempt::REASON_PASSWORD, $user->validatePassword('wrong'));
$this->assertTrue($user->password_ldap === null);
// Valid password (in 'password_ldap' only)
@@ -1757,6 +1762,10 @@
$this->assertTrue(strlen($user->password) == strlen($hash)); // @phpstan-ignore-line
// Note: We test other password algorithms in the Password policy tests
+
+ // Expired password
+ $user->setSetting('password_expired', Carbon::now()->toDateTimeString());
+ $this->assertSame(AuthAttempt::REASON_PASSWORD_EXPIRED, $user->validatePassword('test'));
}
/**

File Metadata

Mime Type
text/plain
Expires
Fri, Apr 3, 11:23 AM (18 h, 45 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
18806355
Default Alt Text
D5403.1775215380.diff (47 KB)

Event Timeline