1
0
Эх сурвалжийг харах

Generic OIDC Support

* Everything should be configurable by env variables
* Basic request tests
Gavin Mogan 5 сар өмнө
parent
commit
441c8e0d4c

+ 2 - 31
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'));

+ 121 - 0
app/Http/Controllers/RemoteOidcController.php

@@ -0,0 +1,121 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use App\Models\UserOidcMapping;
+use Purify;
+use App\Services\EmailService;
+use App\Services\UserOidcService;
+use App\User;
+use Illuminate\Auth\Events\Registered;
+use Illuminate\Http\Request;
+use Illuminate\Support\Facades\Auth;
+use Illuminate\Support\Facades\Hash;
+use Illuminate\Support\Str;
+use App\Rules\EmailNotBanned;
+use App\Rules\PixelfedUsername;
+
+class RemoteOidcController extends Controller
+{
+    protected $fractal;
+
+    public function start(UserOidcService $provider, Request $request)
+    {
+        abort_unless(config('remote-auth.oidc.enabled'), 404);
+        if ($request->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($request->input("state") == $request->session()->pull('oauth2state'), 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')],
+            '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();
+    }
+}

+ 25 - 0
app/Models/UserOidcMapping.php

@@ -0,0 +1,25 @@
+<?php
+
+namespace App\Models;
+
+use App\User;
+use Illuminate\Database\Eloquent\Model;
+use Illuminate\Database\Eloquent\Factories\HasFactory;
+
+class UserOidcMapping extends Model
+{
+    use HasFactory;
+
+    public $timestamps = true;
+
+    protected $fillable = [
+        'user_id',
+        'oidc_id',
+    ];
+
+    public function user()
+    {
+        return $this->belongsTo(User::class);
+    }
+
+}

+ 4 - 1
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();
+        });
     }
 }

+ 25 - 0
app/Rules/EmailNotBanned.php

@@ -0,0 +1,25 @@
+<?php
+
+namespace App\Rules;
+
+use Closure;
+use App\Services\EmailService;
+use Illuminate\Contracts\Validation\ValidationRule;
+
+class EmailNotBanned implements ValidationRule
+{
+    /**
+     * Run the validation rule.
+     *
+     * @param  string  $attribute
+     * @param  mixed  $value
+     * @param  \Closure(string): \Illuminate\Translation\PotentiallyTranslatedString  $fail
+     * @return void
+     */
+    public function validate(string $attribute, mixed $value, Closure $fail): void
+    {
+        if (EmailService::isBanned($value)) {
+            $fail('Email is invalid.');
+        }
+    }
+}

+ 57 - 0
app/Rules/PixelfedUsername.php

@@ -0,0 +1,57 @@
+<?php
+
+namespace App\Rules;
+
+use Closure;
+use App\Util\Lexer\RestrictedNames;
+use Illuminate\Contracts\Validation\ValidationRule;
+
+class PixelfedUsername implements ValidationRule
+{
+    /**
+     * Run the validation rule.
+     *
+     * @param  string  $attribute
+     * @param  mixed  $value
+     * @param  \Closure(string): \Illuminate\Translation\PotentiallyTranslatedString  $fail
+     * @return void
+     */
+    public function validate(string $attribute, mixed $value, Closure $fail): void
+    {
+        $dash = substr_count($value, '-');
+        $underscore = substr_count($value, '_');
+        $period = substr_count($value, '.');
+
+        if (ends_with($value, ['.php', '.js', '.css'])) {
+            $fail('Username is invalid.');
+            return;
+        }
+
+        if (($dash + $underscore + $period) > 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;
+        }
+    }
+}

+ 21 - 0
app/Services/UserOidcService.php

@@ -0,0 +1,21 @@
+<?php
+
+namespace App\Services;
+
+use League\OAuth2\Client\Provider\GenericProvider;
+
+class UserOidcService extends GenericProvider {
+    public static function build()
+    {
+        return new UserOidcService([
+            'clientId' => 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'),
+            'accessTokenResourceOwnerId' => config('remote-auth.oidc.field_id'),
+        ]);
+    }
+}

+ 1 - 0
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",

+ 68 - 3
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"
 }

+ 12 - 0
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'),
+    ],
 ];

+ 29 - 0
database/migrations/2025_01_30_061434_create_user_oidc_mapping_table.php

@@ -0,0 +1,29 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+    /**
+     * Run the migrations.
+     */
+    public function up(): void
+    {
+        Schema::create('user_oidc_mappings', function (Blueprint $table) {
+            $table->id();
+            $table->unsignedInteger('user_id')->index();
+            $table->string('oidc_id')->unique()->index();
+            $table->timestamps();
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     */
+    public function down(): void
+    {
+        Schema::dropIfExists('user_oidc_mappings');
+    }
+};

+ 11 - 0
resources/views/auth/login.blade.php

@@ -111,6 +111,17 @@
                     </form>
                     @endif
 
+                    @if( config('remote-auth.oidc.enabled') )
+                    <hr>
+                    <div class="form-group row mb-0">
+                        <div class="col-md-12">
+                            <a href="/auth/oidc/start" class="btn btn-primary btn-sm btn-block rounded-pill font-weight-bold" style="background: linear-gradient(#6364FF, #563ACC);">
+                                Sign-in with OIDC
+                            </a>
+                        </div>
+                    </div>
+                    @endif
+
                     @if((bool) config_cache('pixelfed.open_registration') || (bool) config_cache('instance.curated_registration.enabled'))
                     <hr>
 

+ 4 - 0
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');

+ 117 - 0
tests/Feature/RemoteOidcTest.php

@@ -0,0 +1,117 @@
+<?php
+
+namespace Tests\Feature;
+
+use App\Models\UserOidcMapping;
+use App\Services\UserOidcService;
+use App\User;
+use Auth;
+use Faker\Factory as Faker;
+use League\OAuth2\Client\Provider\GenericResourceOwner;
+use League\OAuth2\Client\Token\AccessToken;
+use Mockery\Adapter\Phpunit\MockeryPHPUnitIntegration;
+use Mockery\MockInterface;
+use Tests\TestCase;
+
+class RemoteOidcTest extends TestCase
+{
+    use MockeryPHPUnitIntegration;
+
+    public function test_view_oidc_start()
+    {
+        config([
+            'remote-auth.oidc.enabled'=> 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);
+    }
+}