Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
70 commits
Select commit Hold shift + click to select a range
15865a4
Start replacing authentication methods
riasvdv Dec 16, 2025
95fd684
Session info
riasvdv Dec 17, 2025
b8bbd1f
Login modal
riasvdv Dec 17, 2025
32b6be2
Elevated session timeout
riasvdv Dec 17, 2025
ee2bf2f
Cleanup
riasvdv Dec 17, 2025
612cd0c
Mark announcements read
riasvdv Dec 17, 2025
3a0501a
Event deprecations
riasvdv Dec 17, 2025
de1aeb4
Cleanup
riasvdv Dec 18, 2025
30b836e
Merge branch '6.x' into feature/authentication
riasvdv Dec 18, 2025
fda1af5
Impersonation
riasvdv Dec 22, 2025
70d797d
isAdmin & guest
riasvdv Dec 22, 2025
781fbfd
Remove all legacy session handling
riasvdv Dec 22, 2025
0e317a5
Remembered username
riasvdv Dec 22, 2025
845e7e3
Don't call the deprecated methods anymore
riasvdv Dec 22, 2025
0be27a0
Return URLs + phpstan
riasvdv Dec 22, 2025
990bc1c
Merge branch '6.x' into feature/authentication
riasvdv Dec 23, 2025
fffc224
Don't use macro
riasvdv Dec 23, 2025
910ba51
Register redirects on boot
riasvdv Dec 23, 2025
acd19dd
Test fixes
riasvdv Dec 23, 2025
0039259
Merge branch '6.x' into feature/authentication
riasvdv Dec 23, 2025
92c110f
Fix UserTest
riasvdv Dec 23, 2025
9fc2e3b
Fix some more tests
riasvdv Dec 23, 2025
bf998aa
Fix test migrations
riasvdv Dec 23, 2025
859b697
Merge branch '6.x' into feature/authentication
riasvdv Jan 7, 2026
2e973f0
Use Laravel's session cookie name as prefix
riasvdv Jan 7, 2026
c3bcb15
Add test for Impersonation class
riasvdv Jan 7, 2026
bbb4c02
Test for remembered username
riasvdv Jan 7, 2026
ea890fa
Test for AnnouncementsController
riasvdv Jan 7, 2026
bd2cbc3
Get frontend login working
riasvdv Jan 7, 2026
c556232
View fixes
riasvdv Jan 7, 2026
2d79f32
Refactor
riasvdv Jan 8, 2026
b015589
Add .well-known/change-password redirect
riasvdv Jan 8, 2026
57324a4
Set password
riasvdv Jan 8, 2026
4689af8
Merge branch '6.x' into feature/authentication
riasvdv Jan 12, 2026
3ed44b2
Verify email
riasvdv Jan 12, 2026
4e48321
Add tests for controllers
riasvdv Jan 12, 2026
262dfdb
Casing fix + cleanup unused code
riasvdv Jan 12, 2026
55146a5
Require password reset
riasvdv Jan 12, 2026
155373d
GetPasswordResetUrl
riasvdv Jan 12, 2026
b2a150b
SendActivationMail
riasvdv Jan 12, 2026
d13293b
SendPasswordResetEmail
riasvdv Jan 12, 2026
5e569b7
Clean up serviceproviders
riasvdv Jan 14, 2026
e7ef74b
Replace legacy Auth service
riasvdv Jan 14, 2026
38805eb
Fix tests
riasvdv Jan 14, 2026
60fc217
fix-cs
riasvdv Jan 14, 2026
9b2dd5d
phpstan fixes
riasvdv Jan 14, 2026
d01f27a
Add auth service to Twig
riasvdv Jan 14, 2026
b04d9bc
Remove old test
riasvdv Jan 14, 2026
e3d5340
Increase memory limit?
riasvdv Jan 14, 2026
911e85e
Update packages
riasvdv Jan 14, 2026
a33fda5
Maybe xdebug provides info?
riasvdv Jan 14, 2026
d052f46
Cleanup database calls
riasvdv Jan 14, 2026
d5c41e8
Install improvements
riasvdv Jan 14, 2026
7846334
Hash password using Laravel
riasvdv Jan 14, 2026
188745d
Legacy service provider improvements
riasvdv Jan 14, 2026
d1a6518
Merge branch '6.x' into feature/authentication
riasvdv Jan 14, 2026
72870f8
Injecting Auth into UserProvider causes issues on CI
riasvdv Jan 14, 2026
dcd040d
Only start needed services
riasvdv Jan 14, 2026
48fb602
pgsql
riasvdv Jan 14, 2026
4ebb4a6
Add --ci flag just to be sure
riasvdv Jan 14, 2026
66ab5a0
Fix actual error that causes recursion
riasvdv Jan 14, 2026
b6007df
Refactor UserFactory
riasvdv Jan 15, 2026
c9b8f88
Move Auth methods
riasvdv Jan 15, 2026
c4a01f1
Auth tests
riasvdv Jan 15, 2026
c8c8bf9
Fix some tests
riasvdv Jan 15, 2026
d220bad
Fix tests
riasvdv Jan 15, 2026
30fb562
Store password
riasvdv Jan 15, 2026
6f233b0
Add tests for SaveUserController behavior
riasvdv Jan 15, 2026
9f8ced7
phpstan + clean up tests
riasvdv Jan 15, 2026
4d782fe
Skip tests on MySQL as bulk ops currently causes issues with locks in…
riasvdv Jan 16, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .github/workflows/laravel-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ jobs:
services:
# Install Postgres
pgsql:
image: postgres:latest
image: ${{ (matrix.db == 'pgsql') && 'postgres:latest' || '' }}
env:
POSTGRES_USER: root
POSTGRES_PASSWORD: mysecretpassword
Expand All @@ -78,7 +78,7 @@ jobs:
options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 3
# Install MySQL
mysql:
image: mysql:8.0
image: ${{ (matrix.db == 'mysql') && 'mysql:8.0' || '' }}
env:
MYSQL_ROOT_PASSWORD: mysecretpassword
MYSQL_DATABASE: craft_test
Expand Down Expand Up @@ -127,7 +127,7 @@ jobs:
uses: ramsey/composer-install@v3

- name: Execute CMS tests
run: vendor/bin/pest
run: ./vendor/bin/pest --ci

- name: Set version
run: composer config version "6.x-dev"
Expand Down
597 changes: 300 additions & 297 deletions composer.lock

Large diffs are not rendered by default.

68 changes: 66 additions & 2 deletions database/Factories/UserFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,16 @@

namespace CraftCms\Cms\Database\Factories;

use CraftCms\Cms\Element\Models\Element;
use Craft;
use CraftCms\Cms\Auth\Models\WebAuthn;
use CraftCms\Cms\User\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Collection;
use Illuminate\Support\Enumerable;
use Illuminate\Support\Facades\Hash;
use Override;
use RuntimeException;

final class UserFactory extends Factory
{
Expand All @@ -17,12 +23,27 @@ final class UserFactory extends Factory
public function definition(): array
{
return [
'id' => Element::factory(),
'id' => null,
'fullName' => $this->faker->name(),
'firstName' => $this->faker->firstName(),
'lastName' => $this->faker->lastName(),
'username' => $this->faker->userName(),
'email' => $this->faker->email(),
'password' => Hash::make('password'),
'active' => true,
'pending' => false,
'locked' => false,
'suspended' => false,
];
}

public function admin(bool $admin = true): self
{
return $this->state(fn () => [
'admin' => $admin,
]);
}

public function pending(): self
{
return $this->state(fn () => [
Expand Down Expand Up @@ -52,4 +73,47 @@ public function suspended(): self
'suspended' => true,
]);
}

public function createElement(array $attributes = [], ?Model $parent = null): \CraftCms\Cms\User\Elements\User
{
return $this->create($attributes, $parent)->asElement();
}

public function withPasskey(string $credentialId): self
{
return $this->afterCreating(fn (User $user) => WebAuthn::factory()->create([
'userId' => $user->id,
'credentialId' => $credentialId,
]));
}

#[Override]
protected function store(Collection $results): void
{
$results->each(function (User $model) {
foreach ($model->getRelations() as $name => $items) {
if ($items instanceof Enumerable && $items->isEmpty()) {
$model->unsetRelation($name);
}
}

if (! Craft::$app->getElements()->saveElement($element = $model->asElement())) {
dump($element->getErrors());
throw new RuntimeException('Could not save user.');
}

$model->id = $element->id;
$model->exists = true;
$model->save();

$this->createChildren($model);

// Ensure any password is set to the element as well.
$element->password = $model->password;

$model->refresh();
$model->uid = $element->uid;
$model->password = $element->password;
});
}
}
1 change: 1 addition & 0 deletions database/Factories/UserGroupFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ final class UserGroupFactory extends Factory
public function definition(): array
{
return [
'uid' => fake()->uuid(),
'name' => fake()->words(asText: true),
'handle' => fake()->slug(),
'description' => fake()->paragraph(),
Expand Down
26 changes: 26 additions & 0 deletions database/Factories/WebAuthnFactory.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<?php

declare(strict_types=1);

namespace CraftCms\Cms\Database\Factories;

use CraftCms\Cms\Auth\Models\WebAuthn;
use CraftCms\Cms\User\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
use Override;

final class WebAuthnFactory extends Factory
{
protected $model = WebAuthn::class;

#[Override]
public function definition(): array
{
return [
'userId' => User::factory(),
'credentialId' => $this->faker->uuid(),
'dateCreated' => now(),
'dateUpdated' => now(),
];
}
}
2 changes: 1 addition & 1 deletion resources/templates/_special/login.twig
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
{% set staticEmail = staticEmail ?? null %}

{% set generalConfig = app.config.craft.general %}
{% set username = staticEmail ?? (generalConfig.rememberUsernameDuration ? craft.app.user.getRememberedUsername(): '') %}
{% set username = staticEmail ?? (generalConfig.rememberUsernameDuration ? rememberedUsername : '') %}

{% if generalConfig.useEmailAsUsername %}
{% set usernameLabel = 'Email'|t('app') %}
Expand Down
2 changes: 1 addition & 1 deletion resources/templates/set-password.twig
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
name: 'newPassword',
autocomplete: 'new-password',
autofocus: true,
errors: (errors is defined ? errors : null)
errors: errors.get('newPassword')
}) }}
</div>

Expand Down
2 changes: 1 addition & 1 deletion resources/templates/users/_auth-methods.twig
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
id: 'auth-method-setup',
class: ['pane', 'fullwidth']|merge((paneClass ?? [])|explodeClass),
} %}
{% for method in craft.app.auth.getAvailableMethods() %}
{% for method in craft.auth.getAvailableMethods() %}
{% set isActive = method.isActive() %}
{% set headingId = "auth-method-heading-#{loop.index0}" %}

Expand Down
2 changes: 1 addition & 1 deletion resources/templates/users/_password.twig
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
id: 'newPassword',
name: 'newPassword',
autocomplete: 'new-password',
errors: user.getErrors('newPassword'),
errors: sessionErrors.get('newPassword'),
inputAttributes: {
data: {
lpignore: user.isCurrent ? false : 'true',
Expand Down
39 changes: 39 additions & 0 deletions routes/actions.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,12 @@
use CraftCms\Cms\Cms;
use CraftCms\Cms\Edition;
use CraftCms\Cms\Http\Controllers\AddressesController;
use CraftCms\Cms\Http\Controllers\AnnouncementsController;
use CraftCms\Cms\Http\Controllers\ApiController;
use CraftCms\Cms\Http\Controllers\Auth\LoginController;
use CraftCms\Cms\Http\Controllers\Auth\PasskeyController;
use CraftCms\Cms\Http\Controllers\Auth\SessionInfoController;
use CraftCms\Cms\Http\Controllers\Auth\TwoFactorAuthenticationController;
use CraftCms\Cms\Http\Controllers\BaseUpdaterController;
use CraftCms\Cms\Http\Controllers\ConfigSyncController;
use CraftCms\Cms\Http\Controllers\Dashboard\Widgets\CraftSupportController;
Expand Down Expand Up @@ -36,9 +41,11 @@
use CraftCms\Cms\Http\Controllers\Users\ActivateController;
use CraftCms\Cms\Http\Controllers\Users\EnableController;
use CraftCms\Cms\Http\Controllers\Users\ImpersonationController;
use CraftCms\Cms\Http\Controllers\Users\PasswordController;
use CraftCms\Cms\Http\Controllers\Users\PermissionsController;
use CraftCms\Cms\Http\Controllers\Users\PhotoController;
use CraftCms\Cms\Http\Controllers\Users\PreferencesController;
use CraftCms\Cms\Http\Controllers\Users\SaveUserController;
use CraftCms\Cms\Http\Controllers\Users\SuspendController;
use CraftCms\Cms\Http\Controllers\Users\UnlockController;
use CraftCms\Cms\Http\Controllers\Users\UsersController;
Expand All @@ -56,6 +63,7 @@
use CraftCms\Cms\Http\Middleware\RequireToken;
use CraftCms\Cms\Support\Str;
use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken;
use Illuminate\Session\Middleware\StartSession;
use Illuminate\Support\Facades\Route;

/**
Expand All @@ -70,6 +78,31 @@
Cms::config()->cpTrigger.'/'.Cms::config()->actionTrigger.Str::start($route, '/'),
])->all());

/**
* Actions that are accessible both with and without CP can be registered here.
*/
foreach ([
Cms::config()->actionTrigger => [],
implode('/', [
Cms::config()->cpTrigger,
Cms::config()->actionTrigger,
]) => ['craft.cp'],
] as $prefix => $middleware) {
Route::prefix($prefix)->middleware($middleware)->group(function () {
// Auth
Route::post('users/login', [LoginController::class, 'attemptLogin']);
Route::post('auth/verify-totp', [TwoFactorAuthenticationController::class, 'verify']);
Route::post('auth/verify-recovery-code', [TwoFactorAuthenticationController::class, 'verifyRecoveryCode']);
Route::post('auth/passkey-request-options', [PasskeyController::class, 'requestOptions']);
Route::post('users/login-with-passkey', [PasskeyController::class, 'login']);
Route::post('users/login-modal', [LoginController::class, 'showLoginModal']);
Route::any('users/session-info', [SessionInfoController::class, 'show'])->withoutMiddleware(StartSession::class);
Route::any('users/get-elevated-session-timeout', [SessionInfoController::class, 'confirmTimeout']);
Route::post('users/send-password-reset-email', [PasswordController::class, 'sendPasswordResetEmail']);
Route::post('users/save-user', SaveUserController::class);
});
}

/**
* Actions that are accessible without CP can be registered here.
*/
Expand Down Expand Up @@ -275,7 +308,9 @@
Route::middleware('password.confirm')->group(function () {
Route::post('users/impersonate', [ImpersonationController::class, 'impersonate']);
Route::post('users/get-impersonation-url', [ImpersonationController::class, 'getUrl']);
Route::post('users/save-password', [PasswordController::class, 'store']);
});
Route::post('users/mark-announcements-as-read', [AnnouncementsController::class, 'markRead']);

Route::post('users/save-permissions', [PermissionsController::class, 'store']);
Route::post('users/save-preferences', [PreferencesController::class, 'store']);
Expand All @@ -289,6 +324,10 @@
Route::post('users/render-photo-input', [PhotoController::class, 'renderInput']);
Route::post('users/upload-user-photo', [PhotoController::class, 'upload']);
Route::post('users/delete-user-photo', [PhotoController::class, 'destroy']);
Route::post('users/require-password-reset', [PasswordController::class, 'requireReset']);
Route::post('users/remove-password-reset-requirement', [PasswordController::class, 'removeResetRequirement']);
Route::post('users/get-password-reset-url', [PasswordController::class, 'passwordResetUrl']);
Route::post('users/send-activation-email', [ActivateController::class, 'sendActivationEmail']);

// User groups
Route::middleware([
Expand Down
15 changes: 14 additions & 1 deletion routes/cp.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,12 @@

declare(strict_types=1);

use CraftCms\Cms\Auth\Enums\CpAuthPath;
use CraftCms\Cms\Edition;
use CraftCms\Cms\Http\Controllers\Auth\LoginController;
use CraftCms\Cms\Http\Controllers\Auth\SetPasswordController;
use CraftCms\Cms\Http\Controllers\Auth\TwoFactorAuthenticationController;
use CraftCms\Cms\Http\Controllers\Auth\VerifyEmailController;
use CraftCms\Cms\Http\Controllers\Dashboard\DashboardController;
use CraftCms\Cms\Http\Controllers\Entries\CreateEntryController;
use CraftCms\Cms\Http\Controllers\Entries\EntriesIndexController;
Expand Down Expand Up @@ -39,10 +44,18 @@
Route::get('install', [InstallController::class, 'index'])
->middleware([HandleInertiaRequests::class]);

Route::get(CpAuthPath::Login->value, [LoginController::class, 'showLogin']);
Route::get(CpAuthPath::TwoFactorChallenge->value, [TwoFactorAuthenticationController::class, 'showForm']);
Route::get(CpAuthPath::SetPassword->value, [SetPasswordController::class, 'show']);
Route::post(CpAuthPath::SetPassword->value, [SetPasswordController::class, 'store']);
Route::get(CpAuthPath::VerifyEmail->value, [VerifyEmailController::class, 'show']);
Route::post(CpAuthPath::VerifyEmail->value, [VerifyEmailController::class, 'store']);

/**
* Admin requests that require a login
*/
Route::middleware('auth:craft')->group(function () {
Route::middleware(['auth:craft', 'can:accessCp'])->group(function () {
Route::get(CpAuthPath::Logout->value, [LoginController::class, 'logout']);
Route::get('dashboard', DashboardController::class);

Route::get('utilities', [UtilitiesController::class, 'index']);
Expand Down
3 changes: 2 additions & 1 deletion routes/routes.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,6 @@
->prefix(Cms::config()->cpTrigger)
->group(__DIR__.'/cp.php');

Route::middleware(['web', 'craft'])
Route::middleware(['web', 'craft', 'craft.web'])
->name('craft.')
->group(__DIR__.'/web.php');
37 changes: 37 additions & 0 deletions routes/web.php
Original file line number Diff line number Diff line change
@@ -1 +1,38 @@
<?php

use CraftCms\Cms\Auth\Enums\CpAuthPath;
use CraftCms\Cms\Cms;
use CraftCms\Cms\Edition;
use CraftCms\Cms\Http\Controllers\Auth\LoginController;
use CraftCms\Cms\Http\Controllers\Auth\TwoFactorAuthenticationController;
use CraftCms\Cms\Http\Controllers\Auth\VerifyEmailController;
use CraftCms\Cms\Site\Sites;
use Illuminate\Support\Facades\Route;

if (Edition::get()->registersFrontendUserRoutes()) {
if (Cms::config()->loginPath !== false) {
Route::get(Cms::config()->loginPath, [LoginController::class, 'showLogin']);
Route::get(CpAuthPath::TwoFactorChallenge->value, [TwoFactorAuthenticationController::class, 'showForm']);
}

if (Cms::config()->verifyEmailPath !== false) {
Route::get(Cms::config()->verifyEmailPath, [VerifyEmailController::class, 'show']);
Route::post(Cms::config()->verifyEmailPath, [VerifyEmailController::class, 'store']);
}

Route::middleware('auth:craft')->group(function () {
if (Cms::config()->logoutPath !== false) {
Route::get(Cms::config()->logoutPath, [LoginController::class, 'logout']);
}
});
}

if (! is_null(Cms::config()->setPasswordRequestPath)) {
Route::get('.well-known/change-password', function (Sites $sites) {
$uri = Cms::config()->getSetPasswordRequestPath($sites->getCurrentSite()->handle);

abort_if(is_null($uri), 404);

return redirect($uri);
});
}
Loading
Loading