Merge pull request #5608 from halkeye/add-generic-oidc

[Improvement] Generic OIDC Support
pull/5867/head^2
daniel 4 months ago committed by GitHub
commit b3c2781578
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -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'));

@ -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(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();
}
}

@ -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);
}
}

@ -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();
});
}
}

@ -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.');
}
}
}

@ -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;
}
}
}

@ -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'),
'responseResourceOwnerId' => config('remote-auth.oidc.field_id'),
]);
}
}

@ -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",

71
composer.lock generated

@ -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"
}

@ -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'),
],
];

@ -0,0 +1,30 @@
<?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->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');
}
};

@ -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>

@ -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');

@ -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);
}
}
Loading…
Cancel
Save