253 lines
9.3 KiB
PHP
253 lines
9.3 KiB
PHP
<?php
|
|
|
|
namespace App\Http\Controllers\Auth;
|
|
|
|
use App\Http\Controllers\Controller;
|
|
use App\Models\SocialAccount;
|
|
use App\Models\User;
|
|
use Illuminate\Database\UniqueConstraintViolationException;
|
|
use Illuminate\Http\RedirectResponse;
|
|
use Illuminate\Support\Facades\Auth;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Hash;
|
|
use Illuminate\Support\Str;
|
|
use Laravel\Socialite\Contracts\User as SocialiteUser;
|
|
use Laravel\Socialite\Facades\Socialite;
|
|
use Throwable;
|
|
|
|
class OAuthController extends Controller
|
|
{
|
|
/** Providers enabled for OAuth login. */
|
|
private const ALLOWED_PROVIDERS = ['google', 'discord'];
|
|
|
|
/**
|
|
* Redirect the user to the provider's OAuth page.
|
|
*/
|
|
public function redirectToProvider(string $provider): RedirectResponse
|
|
{
|
|
$this->abortIfInvalidProvider($provider);
|
|
|
|
return Socialite::driver($provider)->redirect();
|
|
}
|
|
|
|
/**
|
|
* Handle the provider callback and authenticate the user.
|
|
*/
|
|
public function handleProviderCallback(string $provider): RedirectResponse
|
|
{
|
|
$this->abortIfInvalidProvider($provider);
|
|
|
|
try {
|
|
/** @var SocialiteUser $socialUser */
|
|
$socialUser = Socialite::driver($provider)->user();
|
|
} catch (Throwable) {
|
|
return redirect()->route('login')
|
|
->withErrors(['oauth' => 'Authentication failed. Please try again.']);
|
|
}
|
|
|
|
$providerId = (string) $socialUser->getId();
|
|
$providerEmail = $this->resolveEmail($socialUser);
|
|
$verified = $this->isEmailVerifiedByProvider($provider, $socialUser);
|
|
|
|
// ── 1. Provider account already linked → login ───────────────────────
|
|
$existing = SocialAccount::query()
|
|
->where('provider', $provider)
|
|
->where('provider_id', $providerId)
|
|
->with('user')
|
|
->first();
|
|
|
|
if ($existing !== null && $existing->user !== null) {
|
|
return $this->loginAndRedirect($existing->user);
|
|
}
|
|
|
|
// ── 2. Email match → link to existing account ────────────────────────
|
|
// Covers both verified and unverified users: if the OAuth provider
|
|
// has confirmed this email we can safely link it and mark it verified,
|
|
// preventing a duplicate-email insert when the user had started
|
|
// registration via email but never finished verification.
|
|
if ($providerEmail !== null && $verified) {
|
|
$userByEmail = User::query()
|
|
->where('email', strtolower($providerEmail))
|
|
->first();
|
|
|
|
if ($userByEmail !== null) {
|
|
// If their email was not yet verified, promote it now — the
|
|
// OAuth provider has already verified it on our behalf.
|
|
if ($userByEmail->email_verified_at === null) {
|
|
$userByEmail->forceFill([
|
|
'email_verified_at' => now(),
|
|
'is_active' => true,
|
|
// Keep their onboarding step unless already complete
|
|
'onboarding_step' => $userByEmail->onboarding_step === 'email'
|
|
? 'username'
|
|
: ($userByEmail->onboarding_step ?? 'username'),
|
|
])->save();
|
|
}
|
|
|
|
$this->createSocialAccount($userByEmail, $provider, $providerId, $providerEmail, $socialUser->getAvatar());
|
|
|
|
return $this->loginAndRedirect($userByEmail);
|
|
}
|
|
}
|
|
|
|
// ── 3. Provider email not verified → reject auto-link ────────────────
|
|
if ($providerEmail !== null && ! $verified) {
|
|
return redirect()->route('login')
|
|
->withErrors(['oauth' => 'Your ' . ucfirst($provider) . ' email is not verified. Please verify it and try again.']);
|
|
}
|
|
|
|
// ── 4. No email at all → cannot proceed ──────────────────────────────
|
|
if ($providerEmail === null) {
|
|
return redirect()->route('login')
|
|
->withErrors(['oauth' => 'Could not retrieve your email address from ' . ucfirst($provider) . '. Please register with email.']);
|
|
}
|
|
|
|
// ── 5. New user creation ──────────────────────────────────────────────
|
|
try {
|
|
$user = $this->createOAuthUser($socialUser, $provider, $providerId, $providerEmail);
|
|
} catch (UniqueConstraintViolationException) {
|
|
// Race condition: another request inserted the same email between
|
|
// the lookup above and this insert. Fetch and link instead.
|
|
$user = User::query()->where('email', strtolower($providerEmail))->first();
|
|
|
|
if ($user === null) {
|
|
return redirect()->route('login')
|
|
->withErrors(['oauth' => 'An error occurred during sign in. Please try again.']);
|
|
}
|
|
|
|
$this->createSocialAccount($user, $provider, $providerId, $providerEmail, $socialUser->getAvatar());
|
|
}
|
|
|
|
return $this->loginAndRedirect($user);
|
|
}
|
|
|
|
// ─── Private helpers ─────────────────────────────────────────────────────
|
|
|
|
private function abortIfInvalidProvider(string $provider): void
|
|
{
|
|
abort_unless(in_array($provider, self::ALLOWED_PROVIDERS, true), 404);
|
|
}
|
|
|
|
/**
|
|
* Create social_accounts row linked to a user.
|
|
*/
|
|
private function createSocialAccount(
|
|
User $user,
|
|
string $provider,
|
|
string $providerId,
|
|
?string $providerEmail,
|
|
?string $avatar
|
|
): void {
|
|
SocialAccount::query()->updateOrCreate(
|
|
['provider' => $provider, 'provider_id' => $providerId],
|
|
[
|
|
'user_id' => $user->id,
|
|
'provider_email' => $providerEmail !== null ? strtolower($providerEmail) : null,
|
|
'avatar' => $avatar,
|
|
]
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Create a brand-new user from OAuth data.
|
|
*/
|
|
private function createOAuthUser(
|
|
SocialiteUser $socialUser,
|
|
string $provider,
|
|
string $providerId,
|
|
string $providerEmail
|
|
): User {
|
|
$user = DB::transaction(function () use ($socialUser, $provider, $providerId, $providerEmail): User {
|
|
$name = $this->resolveDisplayName($socialUser, $providerEmail);
|
|
|
|
$user = User::query()->create([
|
|
'username' => null,
|
|
'name' => $name,
|
|
'email' => strtolower($providerEmail),
|
|
'email_verified_at' => now(),
|
|
'password' => Hash::make(Str::random(64)),
|
|
'is_active' => true,
|
|
'onboarding_step' => 'username',
|
|
'username_changed_at' => now(),
|
|
]);
|
|
|
|
$this->createSocialAccount(
|
|
$user,
|
|
$provider,
|
|
$providerId,
|
|
$providerEmail,
|
|
$socialUser->getAvatar()
|
|
);
|
|
|
|
return $user;
|
|
});
|
|
|
|
return $user;
|
|
}
|
|
|
|
/**
|
|
* Login the user and redirect appropriately.
|
|
*/
|
|
private function loginAndRedirect(User $user): RedirectResponse
|
|
{
|
|
Auth::login($user, remember: true);
|
|
|
|
request()->session()->regenerate();
|
|
|
|
$step = strtolower((string) ($user->onboarding_step ?? ''));
|
|
|
|
if (in_array($step, ['username', 'password'], true)) {
|
|
return redirect()->route('setup.username.create');
|
|
}
|
|
|
|
return redirect()->intended(route('dashboard', absolute: false));
|
|
}
|
|
|
|
/**
|
|
* Resolve a usable display name from the social user.
|
|
*/
|
|
private function resolveDisplayName(SocialiteUser $socialUser, string $email): string
|
|
{
|
|
$name = trim((string) ($socialUser->getName() ?? ''));
|
|
|
|
if ($name !== '') {
|
|
return $name;
|
|
}
|
|
|
|
return Str::before($email, '@');
|
|
}
|
|
|
|
/**
|
|
* Best-effort email resolution. Apple can return null email on repeat logins.
|
|
*/
|
|
private function resolveEmail(SocialiteUser $socialUser): ?string
|
|
{
|
|
$email = $socialUser->getEmail();
|
|
|
|
if ($email === null || $email === '') {
|
|
return null;
|
|
}
|
|
|
|
return strtolower(trim($email));
|
|
}
|
|
|
|
/**
|
|
* Determine whether the provider has verified the user's email.
|
|
*
|
|
* - Google: returns email_verified flag in raw data
|
|
* - Discord: returns verified flag in raw data
|
|
* - Apple: only issues tokens for verified Apple IDs
|
|
*/
|
|
private function isEmailVerifiedByProvider(string $provider, SocialiteUser $socialUser): bool
|
|
{
|
|
$raw = (array) ($socialUser->getRaw() ?? []);
|
|
|
|
return match ($provider) {
|
|
'google' => filter_var($raw['email_verified'] ?? false, FILTER_VALIDATE_BOOLEAN),
|
|
'discord' => (bool) ($raw['verified'] ?? false),
|
|
'apple' => true, // Apple only issues tokens for verified Apple IDs
|
|
default => false,
|
|
};
|
|
}
|
|
}
|