Skip to content

Commit 123e676

Browse files
committed
Start replacing authentication methods
1 parent 8f5c5b8 commit 123e676

File tree

13 files changed

+488
-275
lines changed

13 files changed

+488
-275
lines changed

routes/actions.php

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@
66
use CraftCms\Cms\Edition;
77
use CraftCms\Cms\Http\Controllers\AddressesController;
88
use CraftCms\Cms\Http\Controllers\ApiController;
9+
use CraftCms\Cms\Http\Controllers\Auth\LoginController;
10+
use CraftCms\Cms\Http\Controllers\Auth\PasskeyController;
11+
use CraftCms\Cms\Http\Controllers\Auth\TwoFactorAuthenticationController;
912
use CraftCms\Cms\Http\Controllers\BaseUpdaterController;
1013
use CraftCms\Cms\Http\Controllers\ConfigSyncController;
1114
use CraftCms\Cms\Http\Controllers\Dashboard\Widgets\CraftSupportController;
@@ -105,6 +108,13 @@
105108
Route::any('app/process-api-response-headers', [ApiController::class, 'processResponseHeaders']);
106109
Route::any('app/get-utilities-badge-count', [UtilitiesController::class, 'badgeCount']);
107110

111+
// Auth
112+
Route::post('users/login', [LoginController::class, 'attemptLogin']);
113+
Route::post('auth/verify-totp', [TwoFactorAuthenticationController::class, 'verify']);
114+
Route::post('auth/verify-recovery-code', [TwoFactorAuthenticationController::class, 'verifyRecoveryCode']);
115+
Route::post('auth/passkey-request-options', [PasskeyController::class, 'requestOptions']);
116+
Route::post('users/login-with-passkey', [PasskeyController::class, 'login']);
117+
108118
// Updater
109119
Route::prefix('updater')->group(function () {
110120
Route::post('/', [UpdaterController::class, 'index']);

routes/cp.php

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,10 @@
22

33
declare(strict_types=1);
44

5+
use CraftCms\Cms\Auth\Enums\CpAuthPath;
56
use CraftCms\Cms\Edition;
7+
use CraftCms\Cms\Http\Controllers\Auth\LoginController;
8+
use CraftCms\Cms\Http\Controllers\Auth\TwoFactorAuthenticationController;
69
use CraftCms\Cms\Http\Controllers\Dashboard\DashboardController;
710
use CraftCms\Cms\Http\Controllers\Entries\CreateEntryController;
811
use CraftCms\Cms\Http\Controllers\Entries\EntriesIndexController;
@@ -38,10 +41,14 @@
3841
Route::get('install', [InstallController::class, 'index'])
3942
->middleware([HandleInertiaRequests::class]);
4043

44+
Route::get(CpAuthPath::Login->value, [LoginController::class, 'showLogin']);
45+
Route::get(CpAuthPath::TwoFactorChallenge->value, [TwoFactorAuthenticationController::class, 'showForm']);
46+
4147
/**
4248
* Admin requests that require a login
4349
*/
4450
Route::middleware('auth:craft')->group(function () {
51+
Route::get(CpAuthPath::Logout->value, [LoginController::class, 'logout']);
4552
Route::get('dashboard', DashboardController::class);
4653

4754
Route::get('utilities', [UtilitiesController::class, 'index']);

src/Auth/AuthServiceProvider.php

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace CraftCms\Cms\Auth;
6+
7+
use CraftCms\Cms\Edition;
8+
use CraftCms\Cms\User\Elements\User;
9+
use CraftCms\Cms\User\UserPermissions;
10+
use Illuminate\Contracts\Auth\Access\Authorizable;
11+
use Illuminate\Contracts\Hashing\Hasher;
12+
use Illuminate\Foundation\Application;
13+
use Illuminate\Support\Facades\Auth;
14+
use Illuminate\Support\Facades\Config;
15+
use Illuminate\Support\Facades\Gate;
16+
use Illuminate\Support\ServiceProvider;
17+
use Override;
18+
19+
final class AuthServiceProvider extends ServiceProvider
20+
{
21+
#[Override]
22+
public function register(): void
23+
{
24+
Auth::provider('craft', fn (Application $app) => new UserProvider($app->make(Hasher::class)));
25+
26+
if (! Config::has('auth.guards.craft')) {
27+
Config::set('auth.guards.craft', [
28+
'driver' => 'session',
29+
'provider' => 'craft',
30+
]);
31+
}
32+
33+
if (! Config::has('auth.providers.craft')) {
34+
Config::set('auth.providers.craft', [
35+
'driver' => 'craft',
36+
'model' => User::class,
37+
]);
38+
}
39+
40+
/**
41+
* This hooks our permission system into
42+
* Laravel's Gate authorization system
43+
*/
44+
Gate::after(function (Authorizable $user, string $ability, ?bool $result) {
45+
if (! $user instanceof User) {
46+
return null;
47+
}
48+
49+
/**
50+
* Only check our permissions when the
51+
* result was not explicitly set.
52+
*/
53+
if (! is_null($result)) {
54+
return $result;
55+
}
56+
57+
if (
58+
$user->admin ||
59+
Edition::get() === Edition::Solo
60+
) {
61+
return true;
62+
}
63+
64+
if (! isset($user->id)) {
65+
return null;
66+
}
67+
68+
if (! app(UserPermissions::class)->doesUserHavePermission($user->id, $ability)) {
69+
return null;
70+
}
71+
72+
return true;
73+
});
74+
}
75+
}

src/Auth/Enums/CpAuthPath.php

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace CraftCms\Cms\Auth\Enums;
6+
7+
enum CpAuthPath: string
8+
{
9+
case Login = 'login';
10+
case TwoFactorChallenge = 'two-factor-challenge';
11+
case Logout = 'logout';
12+
case SetPassword = 'set-password';
13+
case VerifyEmail = 'verify-email';
14+
case Update = 'update';
15+
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace CraftCms\Cms\Http\Controllers\Auth;
6+
7+
use Craft;
8+
use craft\helpers\User as UserHelper;
9+
use CraftCms\Cms\Config\GeneralConfig;
10+
use CraftCms\Cms\Http\RespondsWithFlash;
11+
use CraftCms\Cms\User\Elements\User;
12+
use Illuminate\Auth\Events\Failed;
13+
use Illuminate\Http\Request;
14+
use Illuminate\Support\Facades\Auth;
15+
use Illuminate\Support\Facades\Event;
16+
use Symfony\Component\HttpFoundation\Response;
17+
18+
abstract readonly class AuthenticationController
19+
{
20+
use RespondsWithFlash;
21+
22+
public function __construct(
23+
protected GeneralConfig $generalConfig,
24+
) {}
25+
26+
protected function completeLogin(Request $request, User $user, bool $remember): Response
27+
{
28+
Auth::guard('craft')
29+
->setRememberDuration((int) $this->generalConfig->rememberedUserSessionDuration / 60)
30+
->login($user, $remember);
31+
32+
return $this->handleSuccessfulLogin($request, $user);
33+
}
34+
35+
protected function handleSuccessfulLogin(Request $request, User $user): Response
36+
{
37+
$returnUrl = Craft::$app->getUser()->getReturnUrl();
38+
if ($request->wantsJson()) {
39+
return $this->asModelSuccess($user, modelName: 'user', data: [
40+
'returnUrl' => $returnUrl,
41+
]);
42+
}
43+
44+
return $this->redirectToPostedUrl($user, $returnUrl);
45+
}
46+
47+
protected function handleLoginFailure(Request $request, ?string $authError = null, ?User $user = null): Response
48+
{
49+
[$authError, $message] = UserHelper::getLoginFailureInfo($authError, $user);
50+
51+
Event::dispatch(new Failed(
52+
guard: 'craft',
53+
user: $user,
54+
credentials: $request->only('loginName', 'password'),
55+
));
56+
57+
return $this->asFailure($message, ['errorCode' => $authError]);
58+
}
59+
}
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace CraftCms\Cms\Http\Controllers\Auth;
6+
7+
use Craft;
8+
use craft\helpers\UrlHelper;
9+
use CraftCms\Cms\Auth\Enums\CpAuthPath;
10+
use CraftCms\Cms\User\Elements\User;
11+
use Illuminate\Http\Request;
12+
use Illuminate\Support\Facades\Auth;
13+
use Illuminate\Support\Timebox;
14+
use RuntimeException;
15+
use Symfony\Component\HttpFoundation\Response;
16+
17+
use function CraftCms\Cms\cp_url;
18+
19+
final readonly class LoginController extends AuthenticationController
20+
{
21+
public function showLogin(Request $request)
22+
{
23+
// see if they're already logged in
24+
if ($user = $request->user()) {
25+
return $this->handleSuccessfulLogin($request, $user);
26+
}
27+
28+
// should we be showing the 2FA form?
29+
if ($request->input('verify')) {
30+
return redirect()->action([TwoFactorAuthenticationController::class, 'showForm']);
31+
}
32+
33+
// TODO: _rerouteWithFallbackTemplate??
34+
return view('craftcms::login');
35+
}
36+
37+
public function attemptLogin(Request $request): Response
38+
{
39+
$request->validate([
40+
'loginName' => ['required', 'string'],
41+
'password' => ['required', 'string'],
42+
'rememberMe' => ['nullable'],
43+
]);
44+
45+
/** @var \CraftCms\Cms\Auth\UserProvider $provider */
46+
$provider = Auth::guard('craft')->getProvider();
47+
$user = $provider->retrieveByCredentials($request->only('loginName', 'password'));
48+
49+
return new Timebox()->call(function () use ($request, $provider, $user) {
50+
if (! $user || $user->password === null) {
51+
return $this->handleLoginFailure($request, User::AUTH_INVALID_CREDENTIALS);
52+
}
53+
54+
if (! $provider->validateCredentials($user, ['password' => $request->input('password')])) {
55+
return $this->handleLoginFailure($request, $user->authError, $user);
56+
}
57+
58+
// Valid credentials
59+
if (config('hashing.rehash_on_login', true)) {
60+
$provider->rehashPasswordIfRequired($user, ['password' => $request->input('password')]);
61+
}
62+
63+
$authService = Craft::$app->getAuth();
64+
if (! $this->generalConfig->disable2fa && $authService->hasActiveMethod($user)) {
65+
$request->session()->put('user.id', $user->id);
66+
67+
if (! $request->isCpRequest() && ! $request->wantsJson()) {
68+
$loginPath = $this->generalConfig->getLoginPath();
69+
70+
if (! $loginPath) {
71+
$request->session()->forget('user.id');
72+
throw new RuntimeException('User requires two-step verification, but the loginPath config setting is disabled.');
73+
}
74+
75+
return redirect(UrlHelper::siteUrl($loginPath, array_filter([
76+
'verify' => 1,
77+
'returnUrl' => $this->getPostedRedirectUrl($user),
78+
])));
79+
}
80+
81+
return redirect()->action([TwoFactorAuthenticationController::class, 'showForm']);
82+
}
83+
84+
// if we're impersonating, pass the user we're impersonating to the complete method
85+
$impersonator = Craft::$app->getUser()->getImpersonator();
86+
if ($impersonator !== null) {
87+
$user = Auth::user() ?? $user;
88+
}
89+
90+
return $this->completeLogin($request, $user, $request->boolean('rememberMe'));
91+
}, 30_000);
92+
}
93+
94+
public function logout(Request $request): Response
95+
{
96+
Auth::guard('craft')->logout();
97+
98+
if ($request->wantsJson()) {
99+
return $this->asSuccess();
100+
}
101+
102+
// Redirect to the login page if this is a control panel request
103+
if ($request->isCpRequest()) {
104+
return redirect(cp_url(CpAuthPath::Login->value));
105+
}
106+
107+
return $this->asSuccess(
108+
redirect: $this->generalConfig->getPostLogoutRedirect()
109+
);
110+
}
111+
}
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace CraftCms\Cms\Http\Controllers\Auth;
6+
7+
use Craft;
8+
use CraftCms\Cms\Auth\Models\WebAuthn;
9+
use CraftCms\Cms\Support\Json;
10+
use CraftCms\Cms\User\Elements\User;
11+
use Illuminate\Http\JsonResponse;
12+
use Illuminate\Http\Request;
13+
use Illuminate\Support\Facades\Auth;
14+
use Symfony\Component\HttpFoundation\Response;
15+
16+
use function CraftCms\Cms\t;
17+
18+
final readonly class PasskeyController extends AuthenticationController
19+
{
20+
public function requestOptions(): JsonResponse
21+
{
22+
return new JsonResponse([
23+
'options' => Craft::$app->getAuth()->getPasskeyRequestOptions(),
24+
]);
25+
}
26+
27+
public function login(Request $request): Response
28+
{
29+
$request->validate([
30+
'requestOptions' => ['required'],
31+
'response' => ['required'],
32+
]);
33+
34+
$duration = $this->generalConfig->userSessionDuration;
35+
36+
$requestOptions = $request->input('requestOptions');
37+
$response = $request->input('response');
38+
$credential = WebAuthn::where('credentialId', Json::decode($response)['id'])->first();
39+
40+
if ($credential === null) {
41+
return $this->asFailure(t('Passkey authentication failed.'));
42+
}
43+
44+
$user = User::findOne(['id' => $credential->userId]);
45+
46+
if ($user === null) {
47+
return $this->handleLoginFailure($request);
48+
}
49+
50+
if (! $user->authenticateWithPasskey($requestOptions, $response)) {
51+
return $this->handleLoginFailure($request, $user->authError, $user);
52+
}
53+
54+
// if we're impersonating, pass the user we're impersonating to the complete method
55+
$userSession = Craft::$app->getUser();
56+
if ($userSession->getImpersonator() !== null) {
57+
$user = Auth::user();
58+
}
59+
60+
return $this->completeLogin($request, $user, $duration > 0);
61+
}
62+
}

0 commit comments

Comments
 (0)