feat: google login
This commit is contained in:
@@ -0,0 +1,192 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Enums\SocialProvider;
|
||||
use Firebase\JWT\JWK;
|
||||
use Firebase\JWT\JWT;
|
||||
use Illuminate\Http\Client\ConnectionException;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Laravel\Socialite\Two\User as SocialiteUser;
|
||||
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
|
||||
use Throwable;
|
||||
|
||||
class AppleIdentityTokenVerifier
|
||||
{
|
||||
private const JWKS_CACHE_KEY = 'social-auth:apple:jwks';
|
||||
|
||||
public function __construct(private SocialAuthenticationNonce $socialAuthenticationNonce) {}
|
||||
|
||||
public function userFromToken(string $identityToken, string $nonce): SocialiteUser
|
||||
{
|
||||
$claims = $this->verifiedClaims($identityToken);
|
||||
|
||||
$this->validateNonce($claims, $nonce);
|
||||
|
||||
return (new SocialiteUser)->setRaw($claims)->map([
|
||||
'id' => Arr::get($claims, 'sub'),
|
||||
'nickname' => null,
|
||||
'name' => null,
|
||||
'email' => Arr::get($claims, 'email'),
|
||||
'avatar' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
public function subjectFromToken(string $identityToken): string
|
||||
{
|
||||
$subject = Arr::get($this->verifiedClaims($identityToken), 'sub');
|
||||
|
||||
if (! is_string($subject) || $subject === '') {
|
||||
abort(401, 'SOCIAL_TOKEN_INVALID');
|
||||
}
|
||||
|
||||
return $subject;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function verifiedClaims(string $identityToken): array
|
||||
{
|
||||
try {
|
||||
$claims = (array) JWT::decode(
|
||||
$identityToken,
|
||||
JWK::parseKeySet($this->jwkSetForToken($identityToken)),
|
||||
);
|
||||
|
||||
$this->validateStandardClaims($claims);
|
||||
|
||||
return $claims;
|
||||
} catch (HttpExceptionInterface $exception) {
|
||||
throw $exception;
|
||||
} catch (ConnectionException) {
|
||||
abort(503, 'SOCIAL_PROVIDER_UNAVAILABLE');
|
||||
} catch (Throwable) {
|
||||
abort(401, 'SOCIAL_TOKEN_INVALID');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function jwkSetForToken(string $identityToken): array
|
||||
{
|
||||
$keyId = $this->tokenKeyId($identityToken);
|
||||
$jwkSet = $this->jwkSet();
|
||||
|
||||
if ($this->jwkSetContains($jwkSet, $keyId)) {
|
||||
return $jwkSet;
|
||||
}
|
||||
|
||||
Cache::forget(self::JWKS_CACHE_KEY);
|
||||
|
||||
return $this->jwkSet();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function jwkSet(): array
|
||||
{
|
||||
return Cache::remember(
|
||||
self::JWKS_CACHE_KEY,
|
||||
now()->addHours(6),
|
||||
function (): array {
|
||||
$url = (string) config('services.apple.jwks_url');
|
||||
|
||||
if ($url === '') {
|
||||
abort(503, 'SOCIAL_PROVIDER_NOT_CONFIGURED');
|
||||
}
|
||||
|
||||
$response = Http::acceptJson()
|
||||
->connectTimeout(3)
|
||||
->timeout(10)
|
||||
->retry([100, 500], throw: false)
|
||||
->get($url);
|
||||
|
||||
$jwkSet = $response->successful() ? $response->json() : null;
|
||||
|
||||
if (! is_array($jwkSet) || ! isset($jwkSet['keys']) || ! is_array($jwkSet['keys'])) {
|
||||
abort(503, 'SOCIAL_PROVIDER_UNAVAILABLE');
|
||||
}
|
||||
|
||||
return $jwkSet;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
private function tokenKeyId(string $identityToken): string
|
||||
{
|
||||
$segments = explode('.', $identityToken);
|
||||
|
||||
if (count($segments) !== 3) {
|
||||
abort(401, 'SOCIAL_TOKEN_INVALID');
|
||||
}
|
||||
|
||||
$header = JWT::jsonDecode(JWT::urlsafeB64Decode($segments[0]));
|
||||
$keyId = $header->kid ?? null;
|
||||
|
||||
if (! is_string($keyId) || $keyId === '') {
|
||||
abort(401, 'SOCIAL_TOKEN_INVALID');
|
||||
}
|
||||
|
||||
return $keyId;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $jwkSet
|
||||
*/
|
||||
private function jwkSetContains(array $jwkSet, string $keyId): bool
|
||||
{
|
||||
return collect($jwkSet['keys'] ?? [])->contains(
|
||||
fn (mixed $key): bool => is_array($key) && ($key['kid'] ?? null) === $keyId,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $claims
|
||||
*/
|
||||
private function validateStandardClaims(array $claims): void
|
||||
{
|
||||
$issuer = (string) config('services.apple.issuer');
|
||||
$tokenIssuer = Arr::get($claims, 'iss');
|
||||
|
||||
if (! is_string($tokenIssuer) || $issuer === '' || ! hash_equals($issuer, $tokenIssuer)) {
|
||||
abort(401, 'SOCIAL_TOKEN_INVALID');
|
||||
}
|
||||
|
||||
$allowedClientIds = collect(explode(',', (string) config('services.apple.client_ids')))
|
||||
->map(fn (string $clientId): string => trim($clientId))
|
||||
->filter()
|
||||
->values()
|
||||
->all();
|
||||
$tokenAudiences = array_filter(
|
||||
Arr::wrap(Arr::get($claims, 'aud')),
|
||||
fn (mixed $audience): bool => is_string($audience),
|
||||
);
|
||||
|
||||
if ($allowedClientIds === []) {
|
||||
abort(503, 'SOCIAL_PROVIDER_NOT_CONFIGURED');
|
||||
}
|
||||
|
||||
if (array_intersect($allowedClientIds, $tokenAudiences) === []) {
|
||||
abort(401, 'SOCIAL_TOKEN_INVALID');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $claims
|
||||
*/
|
||||
private function validateNonce(array $claims, string $nonce): void
|
||||
{
|
||||
if (! $this->socialAuthenticationNonce->consume(
|
||||
SocialProvider::Apple,
|
||||
$nonce,
|
||||
Arr::get($claims, 'nonce'),
|
||||
)) {
|
||||
abort(401, 'SOCIAL_TOKEN_INVALID');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Firebase\JWT\JWT;
|
||||
use Illuminate\Http\Client\ConnectionException;
|
||||
use Illuminate\Http\Client\PendingRequest;
|
||||
use Illuminate\Http\Client\RequestException;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
|
||||
use Throwable;
|
||||
|
||||
class AppleOAuthTokenClient
|
||||
{
|
||||
public function __construct(private AppleIdentityTokenVerifier $identityTokenVerifier) {}
|
||||
|
||||
public function exchangeAuthorizationCode(string $authorizationCode, string $expectedSubject): string
|
||||
{
|
||||
try {
|
||||
$response = $this->request()
|
||||
->post($this->configuredUrl('token_url'), [
|
||||
'client_id' => $this->clientId(),
|
||||
'client_secret' => $this->clientSecret(),
|
||||
'code' => $authorizationCode,
|
||||
'grant_type' => 'authorization_code',
|
||||
]);
|
||||
} catch (HttpExceptionInterface $exception) {
|
||||
throw $exception;
|
||||
} catch (ConnectionException) {
|
||||
abort(503, 'SOCIAL_PROVIDER_UNAVAILABLE');
|
||||
}
|
||||
|
||||
if (! $response->successful()) {
|
||||
$error = $response->json('error');
|
||||
|
||||
if ($error === 'invalid_grant') {
|
||||
abort(401, 'SOCIAL_AUTHORIZATION_CODE_INVALID');
|
||||
}
|
||||
|
||||
abort(503, 'SOCIAL_PROVIDER_UNAVAILABLE');
|
||||
}
|
||||
|
||||
$refreshToken = $response->json('refresh_token');
|
||||
$identityToken = $response->json('id_token');
|
||||
|
||||
if (! is_string($refreshToken) || $refreshToken === '' || ! is_string($identityToken)) {
|
||||
abort(503, 'SOCIAL_PROVIDER_UNAVAILABLE');
|
||||
}
|
||||
|
||||
$returnedSubject = $this->identityTokenVerifier->subjectFromToken($identityToken);
|
||||
|
||||
if (! hash_equals($expectedSubject, $returnedSubject)) {
|
||||
abort(401, 'SOCIAL_TOKEN_INVALID');
|
||||
}
|
||||
|
||||
return $refreshToken;
|
||||
}
|
||||
|
||||
public function revoke(string $refreshToken): void
|
||||
{
|
||||
try {
|
||||
$response = $this->request()
|
||||
->retry(
|
||||
[100, 500],
|
||||
when: fn (Throwable $exception): bool => $exception instanceof ConnectionException
|
||||
|| ($exception instanceof RequestException && $exception->response->serverError()),
|
||||
throw: false,
|
||||
)
|
||||
->post($this->configuredUrl('revoke_url'), [
|
||||
'client_id' => $this->clientId(),
|
||||
'client_secret' => $this->clientSecret(),
|
||||
'token' => $refreshToken,
|
||||
'token_type_hint' => 'refresh_token',
|
||||
]);
|
||||
} catch (HttpExceptionInterface $exception) {
|
||||
throw $exception;
|
||||
} catch (Throwable) {
|
||||
abort(503, 'SOCIAL_REVOCATION_FAILED');
|
||||
}
|
||||
|
||||
if (! $response->successful()) {
|
||||
abort(503, 'SOCIAL_REVOCATION_FAILED');
|
||||
}
|
||||
}
|
||||
|
||||
private function clientSecret(): string
|
||||
{
|
||||
$teamId = (string) config('services.apple.team_id');
|
||||
$keyId = (string) config('services.apple.key_id');
|
||||
$privateKey = $this->privateKey();
|
||||
$issuer = (string) config('services.apple.issuer');
|
||||
|
||||
if ($teamId === '' || $keyId === '' || $issuer === '' || $privateKey === '') {
|
||||
abort(503, 'SOCIAL_PROVIDER_NOT_CONFIGURED');
|
||||
}
|
||||
|
||||
try {
|
||||
$issuedAt = now()->timestamp;
|
||||
|
||||
return JWT::encode([
|
||||
'aud' => $issuer,
|
||||
'exp' => $issuedAt + 300,
|
||||
'iat' => $issuedAt,
|
||||
'iss' => $teamId,
|
||||
'sub' => $this->clientId(),
|
||||
], $privateKey, 'ES256', $keyId);
|
||||
} catch (Throwable) {
|
||||
abort(503, 'SOCIAL_PROVIDER_NOT_CONFIGURED');
|
||||
}
|
||||
}
|
||||
|
||||
private function clientId(): string
|
||||
{
|
||||
$clientId = (string) config('services.apple.client_id');
|
||||
|
||||
if ($clientId === '') {
|
||||
abort(503, 'SOCIAL_PROVIDER_NOT_CONFIGURED');
|
||||
}
|
||||
|
||||
return $clientId;
|
||||
}
|
||||
|
||||
private function configuredUrl(string $key): string
|
||||
{
|
||||
$url = (string) config('services.apple.'.$key);
|
||||
|
||||
if ($url === '') {
|
||||
abort(503, 'SOCIAL_PROVIDER_NOT_CONFIGURED');
|
||||
}
|
||||
|
||||
return $url;
|
||||
}
|
||||
|
||||
private function privateKey(): string
|
||||
{
|
||||
$base64PrivateKey = (string) config('services.apple.private_key_base64');
|
||||
|
||||
if ($base64PrivateKey !== '') {
|
||||
$decodedPrivateKey = base64_decode($base64PrivateKey, true);
|
||||
|
||||
return is_string($decodedPrivateKey) ? $decodedPrivateKey : '';
|
||||
}
|
||||
|
||||
return str_replace('\\n', "\n", (string) config('services.apple.private_key'));
|
||||
}
|
||||
|
||||
private function request(): PendingRequest
|
||||
{
|
||||
return Http::asForm()
|
||||
->acceptJson()
|
||||
->connectTimeout(3)
|
||||
->timeout(10);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Enums\SocialProvider;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
class SocialAuthenticationNonce
|
||||
{
|
||||
public function issue(SocialProvider $provider): string
|
||||
{
|
||||
$nonce = bin2hex(random_bytes(32));
|
||||
|
||||
Cache::put($this->cacheKey($provider, $nonce), true, now()->addMinutes(10));
|
||||
|
||||
return $nonce;
|
||||
}
|
||||
|
||||
public function consume(SocialProvider $provider, string $nonce, mixed $tokenNonce): bool
|
||||
{
|
||||
$nonceWasIssued = Cache::pull($this->cacheKey($provider, $nonce), false);
|
||||
|
||||
return is_string($tokenNonce)
|
||||
&& hash_equals($nonce, $tokenNonce)
|
||||
&& $nonceWasIssued === true;
|
||||
}
|
||||
|
||||
private function cacheKey(SocialProvider $provider, string $nonce): string
|
||||
{
|
||||
return 'social-auth:'.$provider->value.':nonce:'.hash('sha256', $nonce);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Enums\SocialProvider;
|
||||
use GuzzleHttp\Exception\GuzzleException;
|
||||
use Illuminate\Support\Arr;
|
||||
use Laravel\Socialite\Contracts\Factory as SocialiteFactory;
|
||||
use Laravel\Socialite\Contracts\User as SocialiteUser;
|
||||
use Laravel\Socialite\Two\AbstractProvider;
|
||||
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
|
||||
use Throwable;
|
||||
|
||||
class SocialIdentityResolver
|
||||
{
|
||||
public function __construct(
|
||||
private SocialiteFactory $socialite,
|
||||
private AppleIdentityTokenVerifier $appleIdentityTokenVerifier,
|
||||
private SocialAuthenticationNonce $socialAuthenticationNonce,
|
||||
) {}
|
||||
|
||||
public function resolve(SocialProvider $provider, string $identityToken, ?string $nonce): SocialiteUser
|
||||
{
|
||||
try {
|
||||
return match ($provider) {
|
||||
SocialProvider::Google => $this->googleUserFromToken($identityToken, $nonce ?? ''),
|
||||
SocialProvider::Apple => $this->appleIdentityTokenVerifier->userFromToken(
|
||||
$identityToken,
|
||||
$nonce ?? '',
|
||||
),
|
||||
};
|
||||
} catch (HttpExceptionInterface $exception) {
|
||||
throw $exception;
|
||||
} catch (GuzzleException) {
|
||||
abort(503, 'SOCIAL_PROVIDER_UNAVAILABLE');
|
||||
} catch (Throwable) {
|
||||
abort(401, 'SOCIAL_TOKEN_INVALID');
|
||||
}
|
||||
}
|
||||
|
||||
private function googleUserFromToken(string $identityToken, string $nonce): SocialiteUser
|
||||
{
|
||||
if (! config('services.google.client_id')) {
|
||||
abort(503, 'SOCIAL_PROVIDER_NOT_CONFIGURED');
|
||||
}
|
||||
|
||||
/** @var AbstractProvider $provider */
|
||||
$provider = $this->socialite->driver(SocialProvider::Google->value);
|
||||
|
||||
$identity = $provider->userFromToken($identityToken);
|
||||
$rawIdentity = method_exists($identity, 'getRaw') ? $identity->getRaw() : [];
|
||||
|
||||
if (! is_array($rawIdentity) || ! $this->socialAuthenticationNonce->consume(
|
||||
SocialProvider::Google,
|
||||
$nonce,
|
||||
Arr::get($rawIdentity, 'nonce'),
|
||||
)) {
|
||||
abort(401, 'SOCIAL_TOKEN_INVALID');
|
||||
}
|
||||
|
||||
return $identity;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user