Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 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
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
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
30 changes: 30 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 @@ -56,6 +61,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 +76,29 @@
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']);
});
}

/**
* Actions that are accessible without CP can be registered here.
*/
Expand Down Expand Up @@ -276,6 +305,7 @@
Route::post('users/impersonate', [ImpersonationController::class, 'impersonate']);
Route::post('users/get-impersonation-url', [ImpersonationController::class, 'getUrl']);
});
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 Down
10 changes: 10 additions & 0 deletions routes/cp.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@

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\Dashboard\DashboardController;
use CraftCms\Cms\Http\Controllers\Entries\CreateEntryController;
use CraftCms\Cms\Http\Controllers\Entries\EntriesIndexController;
Expand Down Expand Up @@ -39,10 +43,16 @@
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']);

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

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

Route::middleware(['web', 'craft'])
Route::middleware(['web', 'craft', 'craft.web'])
->group(__DIR__.'/web.php');
31 changes: 31 additions & 0 deletions routes/web.php
Original file line number Diff line number Diff line change
@@ -1 +1,32 @@
<?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\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']);
}

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);
});
}
142 changes: 142 additions & 0 deletions src/Auth/AuthServiceProvider.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
<?php

declare(strict_types=1);

namespace CraftCms\Cms\Auth;

use CraftCms\Cms\Cms;
use CraftCms\Cms\Edition;
use CraftCms\Cms\Support\Facades\Users;
use CraftCms\Cms\User\Elements\User;
use CraftCms\Cms\User\UserPermissions;
use Illuminate\Auth\Events\Failed;
use Illuminate\Auth\Events\Login;
use Illuminate\Auth\Events\Logout;
use Illuminate\Auth\Middleware\Authenticate;
use Illuminate\Auth\Middleware\RedirectIfAuthenticated;
use Illuminate\Contracts\Auth\Access\Authorizable;
use Illuminate\Contracts\Hashing\Hasher;
use Illuminate\Foundation\Application;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Session;
use Illuminate\Support\Facades\URL;
use Illuminate\Support\ServiceProvider;
use Override;

final class AuthServiceProvider extends ServiceProvider
{
#[Override]
public function register(): void
{
$this->registerGuard();
$this->registerPermissions();
$this->registerEvents();
}

public function boot(): void
{
$this->bootRedirects();
}

private function bootRedirects(): void
{
Authenticate::redirectUsing(function (Request $request) {
if ($request->isCpRequest()) {
return Cms::config()->cpTrigger.'/login';
}

return Cms::config()->loginPath;
});

RedirectIfAuthenticated::redirectUsing(fn (Request $request) => URL::defaultReturnUrl());
}

private function registerGuard(): void
{
Auth::provider('craft', fn (Application $app) => new UserProvider($app->make(Hasher::class)));

if (! Config::has('auth.guards.craft')) {
Config::set('auth.guards.craft', [
'driver' => 'session',
'provider' => 'craft',
'remember' => floor(Cms::config()->rememberedUserSessionDuration / 60),
]);
}

if (! Config::has('auth.providers.craft')) {
Config::set('auth.providers.craft', [
'driver' => 'craft',
'model' => User::class,
]);
}
}

private function registerPermissions(): void
{
/**
* This hooks our permission system into
* Laravel's Gate authorization system
*/
Gate::after(function (Authorizable $user, string $ability, ?bool $result) {
if (! $user instanceof User) {
return null;
}

/**
* Only check our permissions when the
* result was not explicitly set.
*/
if (! is_null($result)) {
return $result;
}

if (
$user->admin ||
Edition::get() === Edition::Solo
) {
return true;
}

if (! isset($user->id)) {
return null;
}

if (! app(UserPermissions::class)->doesUserHavePermission($user->id, $ability)) {
return null;
}

return true;
});
}

private function registerEvents(): void
{
Event::listen(Login::class, function (Login $event) {
if (! $event->user instanceof User) {
return;
}

Users::handleValidLogin($event->user);

RememberedUsername::set($event->user);

Session::passwordConfirmed();
});

Event::listen(Failed::class, function (Failed $event) {
if (! $event->user instanceof User) {
return;
}

Users::handleInvalidLogin($event->user);
});

Event::listen(Logout::class, function () {
app(Impersonation::class)->setImpersonatorId(null);
});
}
}
15 changes: 15 additions & 0 deletions src/Auth/Enums/CpAuthPath.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?php

declare(strict_types=1);

namespace CraftCms\Cms\Auth\Enums;

enum CpAuthPath: string
{
case Login = 'login';
case TwoFactorChallenge = 'two-factor-challenge';
case Logout = 'logout';
case SetPassword = 'set-password';
case VerifyEmail = 'verify-email';
case Update = 'update';
}
14 changes: 14 additions & 0 deletions src/Auth/Events/InvalidUserToken.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<?php

declare(strict_types=1);

namespace CraftCms\Cms\Auth\Events;

use CraftCms\Cms\User\Elements\User;

final readonly class InvalidUserToken
{
public function __construct(
public ?User $user,
) {}
}
3 changes: 3 additions & 0 deletions src/Auth/Events/LoginUserRetrieved.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@

use CraftCms\Cms\User\Elements\User;

/**
* @event LoginUserRetrieved The event that is triggered after attempting to find a user to sign in
*/
final class LoginUserRetrieved
{
public function __construct(
Expand Down
20 changes: 20 additions & 0 deletions src/Auth/Events/RetrievingLoginUser.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,26 @@

use CraftCms\Cms\User\Elements\User;

/**
* @event RetrievingLoginUser The event that is triggered before attempting to find a user to sign in
*
* ```php
* use CraftCms\Cms\User\Elements\User;
* use CraftCms\Cms\User\Events\RetrievingLoginUser;
* use Illuminate\Support\Facades\Event;
*
* Event::listen(
* RetrievingLoginUser::class,
* function(RetrievingLoginUser $event) {
* // force username-based login
* $event->user = User::find()
* ->username($event->loginName)
* ->addSelect(['users.password', 'users.passwordResetRequired'])
* ->one();
* }
* );
* ```
*/
final class RetrievingLoginUser
{
public function __construct(
Expand Down
Loading
Loading