mirror of https://github.com/pixelfed/pixelfed
Merge pull request #5608 from halkeye/add-generic-oidc
[Improvement] Generic OIDC Supportpull/5867/head^2
commit
b3c2781578
@ -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);
|
||||
}
|
||||
|
||||
}
|
@ -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'),
|
||||
]);
|
||||
}
|
||||
}
|
@ -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');
|
||||
}
|
||||
};
|
@ -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…
Reference in New Issue