Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F117762004
D5403.1775215380.diff
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Flag For Later
Award Token
Authored By
Unknown
Size
47 KB
Referenced Files
None
Subscribers
None
D5403.1775215380.diff
View Options
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
Details
Attached
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)
Attached To
Mode
D5403: Password expiration
Attached
Detach File
Event Timeline