login update

This commit is contained in:
2026-03-05 11:24:37 +01:00
parent 5a33ca55a1
commit f6772f673b
67 changed files with 10640 additions and 116 deletions

View File

@@ -0,0 +1,252 @@
<?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,
};
}
}