Files
daily-meal-api/app/Actions/AuthenticateSocialUser.php
T
leonm 2d794ff0c9
CI / 🧪 Tests Laravel (push) Successful in 3m9s
CI / 🐳 Build & Push Images (push) Successful in 47s
feat: auth with google
2026-08-17 15:13:21 +02:00

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