diff --git a/app/Http/Controllers/RemoteAuthController.php b/app/Http/Controllers/RemoteAuthController.php index 73d4c5592..5f559761a 100644 --- a/app/Http/Controllers/RemoteAuthController.php +++ b/app/Http/Controllers/RemoteAuthController.php @@ -14,6 +14,7 @@ use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Hash; use Illuminate\Support\Str; +use App\Rules\PixelfedUsername; use InvalidArgumentException; use Purify; @@ -359,37 +360,7 @@ class RemoteAuthController extends Controller 'required', 'min:2', 'max:30', - function ($attribute, $value, $fail) { - $dash = substr_count($value, '-'); - $underscore = substr_count($value, '_'); - $period = substr_count($value, '.'); - - if (ends_with($value, ['.php', '.js', '.css'])) { - return $fail('Username is invalid.'); - } - - if (($dash + $underscore + $period) > 1) { - return $fail('Username is invalid. Can only contain one dash (-), period (.) or underscore (_).'); - } - - if (! ctype_alnum($value[0])) { - return $fail('Username is invalid. Must start with a letter or number.'); - } - - if (! ctype_alnum($value[strlen($value) - 1])) { - return $fail('Username is invalid. Must end with a letter or number.'); - } - - $val = str_replace(['_', '.', '-'], '', $value); - if (! ctype_alnum($val)) { - return $fail('Username is invalid. Username must be alpha-numeric and may contain dashes (-), periods (.) and underscores (_).'); - } - - $restricted = RestrictedNames::get(); - if (in_array(strtolower($value), array_map('strtolower', $restricted))) { - return $fail('Username cannot be used.'); - } - }, + new PixelfedUsername(), ], ]); $username = strtolower($request->input('username')); diff --git a/app/Http/Controllers/RemoteOidcController.php b/app/Http/Controllers/RemoteOidcController.php new file mode 100644 index 000000000..e04fcfc9e --- /dev/null +++ b/app/Http/Controllers/RemoteOidcController.php @@ -0,0 +1,121 @@ +user()) { + return redirect('/'); + } + + $url = $provider->getAuthorizationUrl([ + 'scope' => $provider->getDefaultScopes(), + ]); + + $request->session()->put('oauth2state', $provider->getState()); + + return redirect($url); + } + + public function handleCallback(UserOidcService $provider, Request $request) + { + abort_unless(config('remote-auth.oidc.enabled'), 404); + + if ($request->user()) { + return redirect('/'); + } + + abort_unless($request->input("state"), 400); + abort_unless($request->input("code"), 400); + + abort_unless(hash_equals($request->session()->pull('oauth2state'), $request->input("state")), 400, "invalid state"); + + $accessToken = $provider->getAccessToken('authorization_code', [ + 'code' => $request->get('code') + ]); + + $userInfo = $provider->getResourceOwner($accessToken); + $userInfoId = $userInfo->getId(); + $userInfoData = $userInfo->toArray(); + + $mappedUser = UserOidcMapping::where('oidc_id', $userInfoId)->first(); + if ($mappedUser) { + $this->guarder()->login($mappedUser->user); + return redirect('/'); + } + + abort_if(EmailService::isBanned($userInfoData["email"]), 400, 'Banned email.'); + + $user = $this->createUser([ + 'username' => $userInfoData[config('remote-auth.oidc.field_username')], + 'name' => $userInfoData["name"] ?? $userInfoData["display_name"] ?? $userInfoData[config('remote-auth.oidc.field_username')] ?? null, + 'email' => $userInfoData["email"], + ]); + + UserOidcMapping::create([ + 'user_id' => $user->id, + 'oidc_id' => $userInfoId, + ]); + + return redirect('/'); + } + + protected function createUser($data) + { + $this->validate(new Request($data), [ + 'email' => [ + 'required', + 'string', + 'email:strict,filter_unicode,dns,spoof', + 'max:255', + 'unique:users', + new EmailNotBanned(), + ], + 'username' => [ + 'required', + 'min:2', + 'max:30', + 'unique:users,username', + new PixelfedUsername(), + ], + 'name' => 'nullable|max:30', + ]); + + event(new Registered($user = User::create([ + 'name' => Purify::clean($data['name']), + 'username' => $data['username'], + 'email' => $data['email'], + 'password' => Hash::make(Str::password()), + 'email_verified_at' => now(), + 'app_register_ip' => request()->ip(), + 'register_source' => 'oidc', + ]))); + + $this->guarder()->login($user); + + return $user; + } + + protected function guarder() + { + return Auth::guard(); + } +} diff --git a/app/Models/UserOidcMapping.php b/app/Models/UserOidcMapping.php new file mode 100644 index 000000000..8932ff617 --- /dev/null +++ b/app/Models/UserOidcMapping.php @@ -0,0 +1,25 @@ +belongsTo(User::class); + } + +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 9c5fdb7fa..c9eeba627 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -21,6 +21,7 @@ use App\Observers\UserFilterObserver; use App\Observers\UserObserver; use App\Profile; use App\Services\AccountService; +use App\Services\UserOidcService; use App\Status; use App\StatusHashtag; use App\User; @@ -112,6 +113,8 @@ class AppServiceProvider extends ServiceProvider */ public function register() { - // + $this->app->bind(UserOidcService::class, function() { + return UserOidcService::build(); + }); } } diff --git a/app/Rules/EmailNotBanned.php b/app/Rules/EmailNotBanned.php new file mode 100644 index 000000000..3d637ada8 --- /dev/null +++ b/app/Rules/EmailNotBanned.php @@ -0,0 +1,25 @@ + 1) { + $fail('Username is invalid. Can only contain one dash (-), period (.) or underscore (_).'); + return; + } + + if (! ctype_alnum($value[0])) { + $fail('Username is invalid. Must start with a letter or number.'); + return; + } + + if (! ctype_alnum($value[strlen($value) - 1])) { + $fail('Username is invalid. Must end with a letter or number.'); + return; + } + + $val = str_replace(['_', '.', '-'], '', $value); + if (! ctype_alnum($val)) { + $fail('Username is invalid. Username must be alpha-numeric and may contain dashes (-), periods (.) and underscores (_).'); + return; + } + + $restricted = RestrictedNames::get(); + if (in_array(strtolower($value), array_map('strtolower', $restricted))) { + $fail('Username cannot be used.'); + return; + } + } +} diff --git a/app/Services/UserOidcService.php b/app/Services/UserOidcService.php new file mode 100644 index 000000000..c89975935 --- /dev/null +++ b/app/Services/UserOidcService.php @@ -0,0 +1,21 @@ + config('remote-auth.oidc.clientId'), + 'clientSecret' => config('remote-auth.oidc.clientSecret'), + 'redirectUri' => url('auth/oidc/callback'), + 'urlAuthorize' => config('remote-auth.oidc.authorizeURL'), + 'urlAccessToken' => config('remote-auth.oidc.tokenURL'), + 'urlResourceOwnerDetails' => config('remote-auth.oidc.profileURL'), + 'scopes' => config('remote-auth.oidc.scopes'), + 'responseResourceOwnerId' => config('remote-auth.oidc.field_id'), + ]); + } +} diff --git a/composer.json b/composer.json index b9a11b462..07f7a61e2 100644 --- a/composer.json +++ b/composer.json @@ -31,6 +31,7 @@ "laravel/ui": "^4.2", "league/flysystem-aws-s3-v3": "^3.0", "league/iso3166": "^2.1|^4.0", + "league/oauth2-client": "^2.8", "league/uri": "^7.4", "pbmedia/laravel-ffmpeg": "^8.0", "phpseclib/phpseclib": "~2.0", diff --git a/composer.lock b/composer.lock index 19440079d..098e0d7d5 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "a011d3030ab0153865ef4cd6a7b615a3", + "content-hash": "ac363dfc5037ce5d118b7b4a8e75bffe", "packages": [ { "name": "aws/aws-crt-php", @@ -3872,6 +3872,71 @@ ], "time": "2024-09-21T08:32:55+00:00" }, + { + "name": "league/oauth2-client", + "version": "2.8.1", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/oauth2-client.git", + "reference": "9df2924ca644736c835fc60466a3a60390d334f9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/oauth2-client/zipball/9df2924ca644736c835fc60466a3a60390d334f9", + "reference": "9df2924ca644736c835fc60466a3a60390d334f9", + "shasum": "" + }, + "require": { + "ext-json": "*", + "guzzlehttp/guzzle": "^6.5.8 || ^7.4.5", + "php": "^7.1 || >=8.0.0 <8.5.0" + }, + "require-dev": { + "mockery/mockery": "^1.3.5", + "php-parallel-lint/php-parallel-lint": "^1.4", + "phpunit/phpunit": "^7 || ^8 || ^9 || ^10 || ^11", + "squizlabs/php_codesniffer": "^3.11" + }, + "type": "library", + "autoload": { + "psr-4": { + "League\\OAuth2\\Client\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Alex Bilbie", + "email": "hello@alexbilbie.com", + "homepage": "http://www.alexbilbie.com", + "role": "Developer" + }, + { + "name": "Woody Gilk", + "homepage": "https://github.com/shadowhand", + "role": "Contributor" + } + ], + "description": "OAuth 2.0 Client Library", + "keywords": [ + "Authentication", + "SSO", + "authorization", + "identity", + "idp", + "oauth", + "oauth2", + "single sign on" + ], + "support": { + "issues": "https://github.com/thephpleague/oauth2-client/issues", + "source": "https://github.com/thephpleague/oauth2-client/tree/2.8.1" + }, + "time": "2025-02-26T04:37:30+00:00" + }, { "name": "league/oauth2-server", "version": "8.5.5", @@ -12680,7 +12745,7 @@ ], "aliases": [], "minimum-stability": "stable", - "stability-flags": {}, + "stability-flags": [], "prefer-stable": true, "prefer-lowest": false, "platform": { @@ -12693,6 +12758,6 @@ "ext-mbstring": "*", "ext-openssl": "*" }, - "platform-dev": {}, + "platform-dev": [], "plugin-api-version": "2.6.0" } diff --git a/config/remote-auth.php b/config/remote-auth.php index 182bb99a7..c421929fc 100644 --- a/config/remote-auth.php +++ b/config/remote-auth.php @@ -54,4 +54,16 @@ return [ 'limit' => env('PF_LOGIN_WITH_MASTODON_MAX_USES_LIMIT', 3) ] ], + 'oidc' => [ + 'enabled' => env('PF_OIDC_ENABLED', false), + 'clientId' => env('PF_OIDC_CLIENT_ID', false), + 'clientSecret' => env('PF_OIDC_CLIENT_SECRET', false), + 'scopes' => env('PF_OIDC_SCOPES', 'openid profile email'), + 'authorizeURL' => env('PF_OIDC_AUTHORIZE_URL', ''), + 'tokenURL' => env('PF_OIDC_TOKEN_URL', ''), + 'profileURL' => env('PF_OIDC_PROFILE_URL', ''), + 'logoutURL' => env('PF_OIDC_LOGOUT_URL', ''), + 'field_username' => env('PF_OIDC_USERNAME_FIELD', "preferred_username"), + 'field_id' => env('PF_OIDC_FIELD_ID', 'sub'), + ], ]; diff --git a/database/migrations/2025_01_30_061434_create_user_oidc_mapping_table.php b/database/migrations/2025_01_30_061434_create_user_oidc_mapping_table.php new file mode 100644 index 000000000..0986d9aa9 --- /dev/null +++ b/database/migrations/2025_01_30_061434_create_user_oidc_mapping_table.php @@ -0,0 +1,30 @@ +bigIncrements('id'); + $table->bigInteger('user_id')->unsigned()->index(); + $table->string('oidc_id')->unique()->index(); + $table->foreign('user_id')->references('id')->on('users')->onDelete('cascade'); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('user_oidc_mappings'); + } +}; diff --git a/resources/views/auth/login.blade.php b/resources/views/auth/login.blade.php index 0f77f778e..fce2344dc 100644 --- a/resources/views/auth/login.blade.php +++ b/resources/views/auth/login.blade.php @@ -111,6 +111,17 @@ @endif + @if( config('remote-auth.oidc.enabled') ) +
+
+
+ + Sign-in with OIDC + +
+
+ @endif + @if((bool) config_cache('pixelfed.open_registration') || (bool) config_cache('instance.curated_registration.enabled'))
diff --git a/routes/web.php b/routes/web.php index 0d7c368de..7d774b79c 100644 --- a/routes/web.php +++ b/routes/web.php @@ -8,6 +8,10 @@ Route::domain(config('pixelfed.domain.app'))->middleware(['validemail', 'twofact Route::get('authorize_interaction', 'AuthorizeInteractionController@get'); Auth::routes(); + + Route::get('auth/oidc/start', 'RemoteOidcController@start'); + Route::get('auth/oidc/callback', 'RemoteOidcController@handleCallback'); + Route::get('auth/raw/mastodon/start', 'RemoteAuthController@startRedirect'); Route::post('auth/raw/mastodon/config', 'RemoteAuthController@getConfig'); Route::post('auth/raw/mastodon/domains', 'RemoteAuthController@getAuthDomains'); diff --git a/tests/Feature/RemoteOidcTest.php b/tests/Feature/RemoteOidcTest.php new file mode 100644 index 000000000..3e47fd1af --- /dev/null +++ b/tests/Feature/RemoteOidcTest.php @@ -0,0 +1,117 @@ + true, + 'remote-auth.oidc.clientId' => 'fake', + 'remote-auth.oidc.clientSecret' => 'fakeSecret', + 'remote-auth.oidc.authorizeURL' => 'http://fakeserver.oidc/authorizeURL', + 'remote-auth.oidc.tokenURL' => 'http://fakeserver.oidc/tokenURL', + 'remote-auth.oidc.profileURL' => 'http://fakeserver.oidc/profile', + ]); + $response = $this->withoutExceptionHandling()->get('auth/oidc/start'); + + $state = session()->get('oauth2state'); + $callbackUrl = urlencode(url('auth/oidc/callback')); + + $response->assertRedirect("http://fakeserver.oidc/authorizeURL?scope=openid%20profile%20email&state={$state}&response_type=code&approval_prompt=auto&redirect_uri={$callbackUrl}&client_id=fake"); + } + + public function test_view_oidc_callback_new_user() + { + $originalUserCount = User::count(); + $this->assertDatabaseCount('users', $originalUserCount); + + config(['remote-auth.oidc.enabled' => true]); + + $oauthData = array( + "sub" => str_random(10), + "preferred_username" => fake()->unique()->userName, + "email" => fake()->unique()->freeEmail, + ); + + $this->partialMock(UserOidcService::class, function (MockInterface $mock) use ($oauthData) { + $mock->shouldReceive('getAccessToken')->once()->andReturn(new AccessToken(["access_token" => "token" ])); + $mock->shouldReceive('getResourceOwner')->once()->andReturn(new GenericResourceOwner($oauthData, 'sub')); + return $mock; + }); + + $response = $this->withoutExceptionHandling()->withSession([ + 'oauth2state' => 'abc123', + ])->get('auth/oidc/callback?state=abc123&code=1'); + + $response->assertRedirect('/'); + + $mappedUser = UserOidcMapping::where('oidc_id', $oauthData['sub'])->first(); + $this->assertNotNull($mappedUser, "mapping is found"); + $user = $mappedUser->user; + $this->assertEquals($user->username, $oauthData['preferred_username']); + $this->assertEquals($user->email, $oauthData['email']); + $this->assertEquals(Auth::guard()->user()->id, $user->id); + + $this->assertDatabaseCount('users', $originalUserCount+1); + } + + public function test_view_oidc_callback_existing_user() + { + $user = User::create([ + 'name' => fake()->name, + 'username' => fake()->unique()->username, + 'email' => fake()->unique()->freeEmail, + ]); + $originalUserCount = User::count(); + $this->assertDatabaseCount('users', $originalUserCount); + + config(['remote-auth.oidc.enabled' => true]); + + $oauthData = array( + "sub" => str_random(10), + "preferred_username" => $user->username, + "email" => $user->email, + ); + + UserOidcMapping::create([ + 'oidc_id' => $oauthData['sub'], + 'user_id' => $user->id, + ]); + + $this->partialMock(UserOidcService::class, function (MockInterface $mock) use ($oauthData) { + $mock->shouldReceive('getAccessToken')->once()->andReturn(new AccessToken(["access_token" => "token" ])); + $mock->shouldReceive('getResourceOwner')->once()->andReturn(new GenericResourceOwner($oauthData, 'sub')); + return $mock; + }); + + $response = $this->withoutExceptionHandling()->withSession([ + 'oauth2state' => 'abc123', + ])->get('auth/oidc/callback?state=abc123&code=1'); + + $response->assertRedirect('/'); + + $mappedUser = UserOidcMapping::where('oidc_id', $oauthData['sub'])->first(); + $this->assertNotNull($mappedUser, "mapping is found"); + $user = $mappedUser->user; + $this->assertEquals($user->username, $oauthData['preferred_username']); + $this->assertEquals($user->email, $oauthData['email']); + $this->assertEquals(Auth::guard()->user()->id, $user->id); + + $this->assertDatabaseCount('users', $originalUserCount); + } +}