feat: google login
This commit is contained in:
@@ -0,0 +1,175 @@
|
||||
<?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['intent'] !== 'register', 422, 'SOCIAL_ACCOUNT_NOT_FOUND');
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user