mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-04 10:42:23 +00:00
Move the user creation check from OauthLoginService into OauthSetting::allowsUserCreation(), so the login view can use it. The login view now gets a can_register_with_oauth flag. The flag is true when an enabled OAuth provider allows creating new users. The view uses it to show the new auth.register_with_oauth message. Add translations for the message in all locales, plus test coverage.
345 lines
13 KiB
PHP
345 lines
13 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Auth;
|
|
|
|
use App\Auth\Oidc\OidcUser;
|
|
use App\Exceptions\OauthLoginException;
|
|
use App\Models\OauthIdentity;
|
|
use App\Models\OauthSetting;
|
|
use App\Models\Team;
|
|
use App\Models\User;
|
|
use Illuminate\Database\UniqueConstraintViolationException;
|
|
use Illuminate\Support\Facades\Auth;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Hash;
|
|
use Illuminate\Support\Str;
|
|
use Laravel\Fortify\Events\TwoFactorAuthenticationChallenged;
|
|
use Symfony\Component\HttpKernel\Exception\HttpException;
|
|
|
|
class OauthLoginService
|
|
{
|
|
public function login(string $provider, object $oauthUser, OauthSetting $oauthSetting): User
|
|
{
|
|
$email = strtolower(trim((string) $oauthUser->email));
|
|
if ($email === '' || ! filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
|
throw new HttpException(403, 'OAuth provider did not return a valid email address');
|
|
}
|
|
|
|
$user = $provider === 'oidc'
|
|
? $this->resolveOidcUser($oauthUser, $oauthSetting, $email)
|
|
: $this->resolveOauthUser($oauthUser, $oauthSetting, $email);
|
|
|
|
// Choose the team like the password login: restore the last active team,
|
|
// or the sole team. A multi-team user without a valid stored choice gets
|
|
// no session team, so DecideWhatToDoWithUser shows the team selection.
|
|
$user->unsetRelation('teams');
|
|
$team = $user->resolveStoredTeam();
|
|
if (! $team && $user->teams->isEmpty()) {
|
|
$team = $user->recreate_personal_team();
|
|
}
|
|
if ($team) {
|
|
session(['currentTeam' => $team]);
|
|
} else {
|
|
session()->forget('currentTeam');
|
|
}
|
|
|
|
if ($this->requiresTwoFactorChallenge($user)) {
|
|
Auth::logout();
|
|
session()->put([
|
|
'login.id' => $user->getKey(),
|
|
'login.remember' => false,
|
|
]);
|
|
TwoFactorAuthenticationChallenged::dispatch($user);
|
|
|
|
return $user;
|
|
}
|
|
|
|
Auth::login($user);
|
|
auditLog('auth.user.oauth_login_succeeded', [
|
|
'team_id' => $team?->id,
|
|
'resource' => 'user',
|
|
'user_name' => $user->name,
|
|
'actor_id' => $user->id,
|
|
'actor_name' => $user->name,
|
|
'actor_email' => $user->email,
|
|
'provider' => $provider,
|
|
]);
|
|
|
|
return $user;
|
|
}
|
|
|
|
public function requiresTwoFactorChallenge(User $user): bool
|
|
{
|
|
return $user->hasEnabledTwoFactorAuthentication();
|
|
}
|
|
|
|
/**
|
|
* @return array{0: mixed, 1: mixed}
|
|
*/
|
|
private function oidcIssuerAndSubject(object $oauthUser): array
|
|
{
|
|
$issuer = $oauthUser instanceof OidcUser && filled($oauthUser->issuer)
|
|
? $oauthUser->issuer
|
|
: data_get($oauthUser->user, 'iss');
|
|
$subject = $oauthUser instanceof OidcUser && filled($oauthUser->subject)
|
|
? $oauthUser->subject
|
|
: data_get($oauthUser->user, 'sub', $oauthUser->id);
|
|
|
|
return [$issuer, $subject];
|
|
}
|
|
|
|
private function resolveOauthUser(object $oauthUser, OauthSetting $oauthSetting, string $email): User
|
|
{
|
|
$provider = $oauthSetting->provider;
|
|
$providerUserId = $oauthUser->id ?? null;
|
|
if (
|
|
(! is_string($providerUserId) && ! is_int($providerUserId))
|
|
|| (is_string($providerUserId) && trim($providerUserId) === '')
|
|
) {
|
|
throw new HttpException(403, 'OAuth provider did not return a valid user ID');
|
|
}
|
|
$providerUserId = (string) $providerUserId;
|
|
$rawClaims = is_array($oauthUser->user ?? null) ? $oauthUser->user : [];
|
|
|
|
if ($provider === 'google' && ! $this->isInGoogleWorkspace($oauthSetting->tenant, data_get($rawClaims, 'hd'))) {
|
|
throw new HttpException(403, 'Google account is not in the configured Workspace');
|
|
}
|
|
|
|
$issuer = OauthIdentityIssuer::forSetting($oauthSetting);
|
|
if ($issuer === null) {
|
|
throw new HttpException(403, 'OAuth provider instance is not configured');
|
|
}
|
|
|
|
$identityKey = [
|
|
'provider' => $provider,
|
|
'issuer' => $issuer,
|
|
'provider_user_id' => $providerUserId,
|
|
];
|
|
|
|
try {
|
|
return DB::transaction(function () use ($oauthUser, $oauthSetting, $email, $provider, $issuer, $providerUserId, $rawClaims, $identityKey): User {
|
|
$identity = OauthIdentity::where($identityKey)->first();
|
|
|
|
if ($identity) {
|
|
$identity->update([
|
|
'email' => $email,
|
|
'raw_claims' => $rawClaims,
|
|
'last_login_at' => now(),
|
|
]);
|
|
|
|
return $identity->user;
|
|
}
|
|
|
|
$user = User::whereEmail($email)->first();
|
|
|
|
// Before OAuth identities existed, OAuth sign-in matched users by email only.
|
|
// Users from that time keep signing in without email delivery, so their
|
|
// first identity links without a provider verification claim. A user who
|
|
// has a password never gets this exemption. This check runs first, so an
|
|
// unverified email cannot reveal a linked account.
|
|
$isPreUpgradeOauthUser = $user?->created_before_oauth_identities === true && ! $user->hasPassword();
|
|
if (! $isPreUpgradeOauthUser && ! $this->hasVerifiedEmail($provider, $rawClaims, $email)) {
|
|
throw new OauthLoginException('OAuth provider did not verify the email address', 'auth.failed.oauth_email_unverified');
|
|
}
|
|
|
|
if ($user?->oauthIdentities()->exists()) {
|
|
throw new OauthLoginException('OAuth identity cannot be linked to this account', 'auth.failed.oauth_already_linked');
|
|
}
|
|
|
|
if (! $user) {
|
|
if (! $oauthSetting->allowsUserCreation()) {
|
|
throw new OauthLoginException('Registration is disabled', 'auth.registration_disabled');
|
|
}
|
|
|
|
$user = $this->createUser($oauthUser->name ?: $email, $email, $oauthSetting);
|
|
}
|
|
|
|
OauthIdentity::create([
|
|
'user_id' => $user->id,
|
|
'provider' => $provider,
|
|
'issuer' => $issuer,
|
|
'provider_user_id' => $providerUserId,
|
|
'email' => $email,
|
|
'raw_claims' => $rawClaims,
|
|
'last_login_at' => now(),
|
|
]);
|
|
|
|
if ($user->created_before_oauth_identities) {
|
|
$user->forceFill(['created_before_oauth_identities' => false])->save();
|
|
}
|
|
|
|
return $user;
|
|
});
|
|
} catch (UniqueConstraintViolationException $exception) {
|
|
return OauthIdentity::where($identityKey)->first()?->user ?? throw $exception;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* An empty hosted domain allows every Google account. A "*" allows any
|
|
* Workspace account. Domains are compared without case and spaces.
|
|
*/
|
|
private function isInGoogleWorkspace(?string $hostedDomain, mixed $hdClaim): bool
|
|
{
|
|
$hostedDomain = strtolower(trim((string) $hostedDomain));
|
|
if ($hostedDomain === '') {
|
|
return true;
|
|
}
|
|
|
|
$hdClaim = is_string($hdClaim) ? strtolower(trim($hdClaim)) : '';
|
|
if ($hdClaim === '') {
|
|
return false;
|
|
}
|
|
|
|
return $hostedDomain === '*' || $hdClaim === $hostedDomain;
|
|
}
|
|
|
|
/**
|
|
* GitHub and Bitbucket select only verified primary email addresses in
|
|
* their Socialite providers. Microsoft Graph has no verification flag, but
|
|
* Entra ID only issues a user principal name in a domain the tenant
|
|
* verified, so only that name is trusted. GitLab confirms the primary
|
|
* email of an account. Other providers must return an explicit boolean
|
|
* verification claim in the raw provider response.
|
|
*
|
|
* @param array<string, mixed> $rawClaims
|
|
*/
|
|
private function hasVerifiedEmail(string $provider, array $rawClaims, string $email): bool
|
|
{
|
|
return match ($provider) {
|
|
'github', 'bitbucket' => true,
|
|
'discord' => data_get($rawClaims, 'verified') === true,
|
|
'azure' => strtolower((string) data_get($rawClaims, 'userPrincipalName')) === $email,
|
|
'gitlab' => filled(data_get($rawClaims, 'confirmed_at')),
|
|
default => data_get($rawClaims, 'email_verified') === true,
|
|
};
|
|
}
|
|
|
|
private function resolveOidcUser(object $oauthUser, OauthSetting $oauthSetting, string $email): User
|
|
{
|
|
[$issuer, $subject] = $this->oidcIssuerAndSubject($oauthUser);
|
|
$emailVerified = ($oauthUser instanceof OidcUser && $oauthUser->emailVerified)
|
|
|| data_get($oauthUser->user, 'email_verified') === true;
|
|
|
|
if (! is_string($issuer) || $issuer === '' || ! is_string($subject) || $subject === '') {
|
|
throw new HttpException(403, 'OIDC provider did not return issuer and subject claims');
|
|
}
|
|
|
|
if ($oauthSetting->require_email_verified && ! $emailVerified) {
|
|
throw new OauthLoginException('OIDC provider did not verify the email address', 'auth.failed.oauth_email_unverified');
|
|
}
|
|
|
|
$rawClaims = is_array($oauthUser->user ?? null) ? $oauthUser->user : [];
|
|
|
|
$identityKey = [
|
|
'provider' => 'oidc',
|
|
'issuer' => $issuer,
|
|
'provider_user_id' => $subject,
|
|
];
|
|
|
|
try {
|
|
return DB::transaction(function () use ($oauthUser, $oauthSetting, $email, $issuer, $subject, $emailVerified, $rawClaims, $identityKey): User {
|
|
$identity = OauthIdentity::where($identityKey)->first();
|
|
|
|
if ($identity) {
|
|
$identity->update([
|
|
'email' => $email,
|
|
'raw_claims' => $rawClaims,
|
|
'last_login_at' => now(),
|
|
]);
|
|
|
|
return $identity->user;
|
|
}
|
|
|
|
$user = User::whereEmail($email)->first();
|
|
|
|
// Linking a new OIDC identity to an existing local account by email
|
|
// is account takeover unless the provider attests the email. This
|
|
// guard is independent of the require_email_verified toggle, which
|
|
// only governs the broader login flow.
|
|
if ($user && ! $emailVerified) {
|
|
throw new OauthLoginException('OIDC provider must verify the email address before linking to an existing account', 'auth.failed.oauth_email_unverified');
|
|
}
|
|
|
|
if ($user?->oauthIdentities()->exists()) {
|
|
throw new OauthLoginException('OAuth identity cannot be linked to this account', 'auth.failed.oauth_already_linked');
|
|
}
|
|
|
|
if (! $user) {
|
|
if (! $oauthSetting->allowsUserCreation()) {
|
|
throw new OauthLoginException('Registration is disabled', 'auth.registration_disabled');
|
|
}
|
|
|
|
$user = $this->createUser($oauthUser->name ?: $email, $email, $oauthSetting);
|
|
}
|
|
|
|
OauthIdentity::create([
|
|
'user_id' => $user->id,
|
|
'provider' => 'oidc',
|
|
'issuer' => $issuer,
|
|
'provider_user_id' => $subject,
|
|
'email' => $email,
|
|
'raw_claims' => $rawClaims,
|
|
'last_login_at' => now(),
|
|
]);
|
|
|
|
return $user;
|
|
});
|
|
} catch (UniqueConstraintViolationException $exception) {
|
|
return OauthIdentity::where($identityKey)->first()?->user ?? throw $exception;
|
|
}
|
|
}
|
|
|
|
private function createUser(string $name, string $email, OauthSetting $oauthSetting): User
|
|
{
|
|
if (User::count() === 0) {
|
|
$user = (new User)->forceFill([
|
|
'id' => 0,
|
|
'name' => $name,
|
|
'email' => $email,
|
|
'password' => Hash::make(Str::random(64)),
|
|
]);
|
|
$user->save();
|
|
|
|
$team = $user->teams()->first() ?? Team::find(0);
|
|
if ($team !== null && ! $user->teams()->where('team_id', $team->id)->exists()) {
|
|
$user->teams()->attach($team, ['role' => 'owner']);
|
|
}
|
|
|
|
instanceSettings()->update(['is_registration_enabled' => false]);
|
|
|
|
return $user;
|
|
}
|
|
|
|
if ($oauthSetting->auto_join_root_team) {
|
|
return $this->createRootTeamOnlyUser($name, $email);
|
|
}
|
|
|
|
return User::create([
|
|
'name' => $name,
|
|
'email' => $email,
|
|
'password' => Hash::make(Str::random(64)),
|
|
]);
|
|
}
|
|
|
|
private function createRootTeamOnlyUser(string $name, string $email): User
|
|
{
|
|
return DB::transaction(function () use ($name, $email) {
|
|
$rootTeam = Team::find(0);
|
|
if ($rootTeam === null) {
|
|
throw new HttpException(403, 'Root team is not available for OAuth user provisioning');
|
|
}
|
|
|
|
$user = User::withoutEvents(fn () => User::create([
|
|
'name' => $name,
|
|
'email' => $email,
|
|
'password' => Hash::make(Str::random(64)),
|
|
]));
|
|
|
|
$user->teams()->attach($rootTeam, ['role' => 'member']);
|
|
|
|
return $user;
|
|
});
|
|
}
|
|
}
|