175 lines
6.1 KiB
PHP
175 lines
6.1 KiB
PHP
<?php
|
|
|
|
namespace App\Actions;
|
|
|
|
use App\Enums\SocialProvider;
|
|
use App\Models\SocialAccount;
|
|
use App\Models\User;
|
|
use App\Services\AppleOAuthTokenClient;
|
|
use App\Services\SocialIdentityResolver;
|
|
use Illuminate\Contracts\Cache\LockTimeoutException;
|
|
use Illuminate\Support\Facades\Cache;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Str;
|
|
use Laravel\Socialite\Contracts\User as SocialiteUser;
|
|
|
|
class AuthenticateSocialUser
|
|
{
|
|
public function __construct(
|
|
private SocialIdentityResolver $identityResolver,
|
|
private AppleOAuthTokenClient $appleOAuthTokenClient,
|
|
) {}
|
|
|
|
/**
|
|
* @param array<string, mixed> $data
|
|
*/
|
|
public function execute(array $data): User
|
|
{
|
|
$provider = SocialProvider::from((string) $data['provider']);
|
|
$identity = $this->identityResolver->resolve(
|
|
$provider,
|
|
(string) $data['idToken'],
|
|
isset($data['nonce']) ? (string) $data['nonce'] : null,
|
|
);
|
|
|
|
$this->ensureUsableIdentity($identity);
|
|
$email = strtolower(trim((string) $identity->getEmail()));
|
|
$providerRefreshToken = $provider === SocialProvider::Apple
|
|
? $this->appleOAuthTokenClient->exchangeAuthorizationCode(
|
|
(string) ($data['authorizationCode'] ?? ''),
|
|
(string) $identity->getId(),
|
|
)
|
|
: null;
|
|
|
|
try {
|
|
return Cache::lock('social-auth:user:'.hash('sha256', $email), 10)
|
|
->block(5, fn (): User => DB::transaction(function () use ($data, $email, $provider, $providerRefreshToken, $identity): User {
|
|
$providerUserId = (string) $identity->getId();
|
|
$socialAccount = SocialAccount::query()
|
|
->where('provider', $provider)
|
|
->where('provider_user_id', $providerUserId)
|
|
->lockForUpdate()
|
|
->first();
|
|
|
|
if ($socialAccount) {
|
|
$user = $socialAccount->user;
|
|
|
|
abort_if(! $user, 409, 'SOCIAL_ACCOUNT_CONFLICT');
|
|
|
|
if ($providerRefreshToken !== null) {
|
|
$socialAccount->update([
|
|
'provider_refresh_token' => $providerRefreshToken,
|
|
]);
|
|
}
|
|
|
|
return $this->prepareUserForLogin($user, $data);
|
|
}
|
|
|
|
$user = User::query()
|
|
->where('email', $email)
|
|
->lockForUpdate()
|
|
->first();
|
|
|
|
if (! $user) {
|
|
abort_if(($data['termsAccepted'] ?? false) !== true, 422, 'TERMS_ACCEPTANCE_REQUIRED');
|
|
|
|
$user = User::create([
|
|
'email' => $email,
|
|
'email_verified_at' => now(),
|
|
'locale' => $data['locale'] ?? 'en',
|
|
'name' => $this->uniqueUsername($this->preferredName($provider, $identity, $data), $email),
|
|
'password' => null,
|
|
'terms_accepted_at' => now(),
|
|
]);
|
|
} else {
|
|
abort_if($user->isSuspended(), 403, 'ACCOUNT_SUSPENDED');
|
|
|
|
if (! $user->hasVerifiedEmail()) {
|
|
$user->markEmailAsVerified();
|
|
}
|
|
}
|
|
|
|
$user->socialAccounts()->create([
|
|
'provider' => $provider,
|
|
'provider_refresh_token' => $providerRefreshToken,
|
|
'provider_user_id' => $providerUserId,
|
|
]);
|
|
|
|
return $this->prepareUserForLogin($user, $data);
|
|
}, 3));
|
|
} catch (LockTimeoutException) {
|
|
abort(409, 'SOCIAL_AUTH_IN_PROGRESS');
|
|
}
|
|
}
|
|
|
|
private function ensureUsableIdentity(SocialiteUser $identity): void
|
|
{
|
|
$providerUserId = $identity->getId();
|
|
$email = $identity->getEmail();
|
|
$rawIdentity = $identity->getRaw();
|
|
$emailVerified = filter_var(
|
|
$rawIdentity['email_verified'] ?? $rawIdentity['verified_email'] ?? false,
|
|
FILTER_VALIDATE_BOOL,
|
|
);
|
|
|
|
abort_if(! is_string($providerUserId) || trim($providerUserId) === '', 401, 'SOCIAL_TOKEN_INVALID');
|
|
abort_if(! is_string($email) || filter_var($email, FILTER_VALIDATE_EMAIL) === false, 401, 'SOCIAL_TOKEN_INVALID');
|
|
abort_if($emailVerified !== true, 401, 'SOCIAL_EMAIL_NOT_VERIFIED');
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $data
|
|
*/
|
|
private function prepareUserForLogin(User $user, array $data): User
|
|
{
|
|
abort_if($user->isSuspended(), 403, 'ACCOUNT_SUSPENDED');
|
|
|
|
if (isset($data['locale']) && $user->locale !== $data['locale']) {
|
|
$user->forceFill(['locale' => $data['locale']])->save();
|
|
}
|
|
|
|
return $user->fresh();
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $data
|
|
*/
|
|
private function preferredName(
|
|
SocialProvider $provider,
|
|
SocialiteUser $identity,
|
|
array $data,
|
|
): ?string {
|
|
if ($provider === SocialProvider::Apple && isset($data['name']) && is_string($data['name'])) {
|
|
return $data['name'];
|
|
}
|
|
|
|
return $identity->getName();
|
|
}
|
|
|
|
private function uniqueUsername(?string $preferredName, string $email): string
|
|
{
|
|
$source = filled($preferredName) ? $preferredName : Str::before($email, '@');
|
|
$base = Str::of((string) $source)
|
|
->ascii()
|
|
->lower()
|
|
->replaceMatches('/[^a-z0-9_.-]+/', '.')
|
|
->trim('.-_')
|
|
->limit(24, '')
|
|
->toString();
|
|
|
|
if (mb_strlen($base) < 3) {
|
|
$base = 'user';
|
|
}
|
|
|
|
if (! User::withTrashed()->where('name', $base)->exists()) {
|
|
return $base;
|
|
}
|
|
|
|
do {
|
|
$candidate = $base.'-'.Str::lower(Str::random(6));
|
|
} while (User::withTrashed()->where('name', $candidate)->exists());
|
|
|
|
return $candidate;
|
|
}
|
|
}
|