7a137 google auth (#520)

* google oauth

* fix lint

* cleanup debug

* Oauth changes, alert message, email validation

* fix exception and inline return condition

* fix google oauth

* UI fixes

* fix provider user

---------

Co-authored-by: Julien Nahum <julien@nahum.net>
This commit is contained in:
Favour Olayinka 2024-08-19 14:22:57 +01:00 committed by GitHub
parent 5049ba7fb1
commit 7ac8503201
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 273 additions and 52 deletions

View File

@ -84,5 +84,6 @@ CADDY_AUTHORIZED_IPS=
GOOGLE_CLIENT_ID= GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET= GOOGLE_CLIENT_SECRET=
GOOGLE_REDIRECT_URL=http://localhost:3000/settings/connections/callback/google GOOGLE_REDIRECT_URL=http://localhost:3000/settings/connections/callback/google
GOOGLE_AUTH_REDIRECT_URL=http://localhost:3000/oauth/google/callback
GOOGLE_FONTS_API_KEY= GOOGLE_FONTS_API_KEY=

View File

@ -2,12 +2,12 @@
namespace App\Http\Controllers\Auth; namespace App\Http\Controllers\Auth;
use App\Exceptions\EmailTakenException;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Integrations\OAuth\OAuthProviderService;
use App\Models\OAuthProvider; use App\Models\OAuthProvider;
use App\Models\User; use App\Models\User;
use App\Models\Workspace;
use Illuminate\Foundation\Auth\AuthenticatesUsers; use Illuminate\Foundation\Auth\AuthenticatesUsers;
use Laravel\Socialite\Facades\Socialite;
class OAuthController extends Controller class OAuthController extends Controller
{ {
@ -31,11 +31,11 @@ class OAuthController extends Controller
* @param string $provider * @param string $provider
* @return \Illuminate\Http\RedirectResponse * @return \Illuminate\Http\RedirectResponse
*/ */
public function redirect($provider) public function redirect(OAuthProviderService $provider)
{ {
return [ return response()->json([
'url' => Socialite::driver($provider)->stateless()->redirect()->getTargetUrl(), 'url' => $provider->getDriver()->setRedirectUrl(config('services.google.auth_redirect'))->getRedirectUrl()
]; ]);
} }
/** /**
@ -44,69 +44,103 @@ class OAuthController extends Controller
* @param string $driver * @param string $driver
* @return \Illuminate\Http\Response * @return \Illuminate\Http\Response
*/ */
public function handleCallback($provider) public function handleCallback(OAuthProviderService $provider)
{ {
$user = Socialite::driver($provider)->stateless()->user(); try {
$user = $this->findOrCreateUser($provider, $user); $driverUser = $provider->getDriver()->setRedirectUrl(config('services.google.auth_redirect'))->getUser();
} catch (\Exception $e) {
return $this->error([
"message" => "OAuth service failed to authenticate: " . $e->getMessage()
]);
}
$user = $this->findOrCreateUser($provider, $driverUser);
if (!$user) {
return $this->error([
"message" => "User not found."
]);
}
if ($user->has_registered) {
return $this->error([
"message" => "This email is already registered. Please sign in with your password."
]);
}
$this->guard()->setToken( $this->guard()->setToken(
$token = $this->guard()->login($user) $token = $this->guard()->login($user)
); );
return view('oauth/callback', [ return response()->json([
'token' => $token, 'token' => $token,
'token_type' => 'bearer', 'token_type' => 'bearer',
'expires_in' => $this->guard()->getPayload()->get('exp') - time(), 'expires_in' => $this->guard()->getPayload()->get('exp') - time(),
'new_user' => $user->new_user
]); ]);
} }
/** /**
* @param string $provider * @p aram \Laravel\Socialite\Contracts\User $socialiteUser
* @param \Laravel\Socialite\Contracts\User $sUser * @return \App\Models\User | null
* @return \App\Models\User
*/ */
protected function findOrCreateUser($provider, $user) protected function findOrCreateUser($provider, $socialiteUser)
{ {
$oauthProvider = OAuthProvider::where('provider', $provider) $oauthProvider = OAuthProvider::where('provider', $provider)
->where('provider_user_id', $user->getId()) ->where('provider_user_id', $socialiteUser->getId())
->first(); ->first();
if ($oauthProvider) { if ($oauthProvider) {
$oauthProvider->update([ $oauthProvider->update([
'access_token' => $user->token, 'access_token' => $socialiteUser->token,
'refresh_token' => $user->refreshToken, 'refresh_token' => $socialiteUser->refreshToken,
]); ]);
return $oauthProvider->user; return $oauthProvider->user;
} }
if (User::where('email', $user->getEmail())->exists()) {
throw new EmailTakenException(); if (!$provider->getDriver()->canCreateUser()) {
return null;
} }
return $this->createUser($provider, $user); $email = strtolower($socialiteUser->getEmail());
$user = User::whereEmail($email)->first();
if ($user) {
$user->has_registered = true;
return $user;
} }
/**
* @param string $provider
* @param \Laravel\Socialite\Contracts\User $sUser
* @return \App\Models\User
*/
protected function createUser($provider, $sUser)
{
$user = User::create([ $user = User::create([
'name' => $sUser->getName(), 'name' => $socialiteUser->getName(),
'email' => $sUser->getEmail(), 'email' => $email,
'email_verified_at' => now(), 'email_verified_at' => now(),
]); ]);
$user->oauthProviders()->create([ // Create and sync workspace
'provider' => $provider, $workspace = Workspace::create([
'provider_user_id' => $sUser->getId(), 'name' => 'My Workspace',
'access_token' => $sUser->token, 'icon' => '🧪',
'refresh_token' => $sUser->refreshToken,
]); ]);
$user->workspaces()->sync([
$workspace->id => [
'role' => User::ROLE_ADMIN,
],
], false);
$user->new_user = true;
OAuthProvider::create(
[
'user_id' => $user->id,
'provider' => $provider,
'provider_user_id' => $socialiteUser->getId(),
'access_token' => $socialiteUser->token,
'refresh_token' => $socialiteUser->refreshToken,
'name' => $socialiteUser->getName(),
'email' => $socialiteUser->getEmail(),
]
);
return $user; return $user;
} }
} }

View File

@ -10,7 +10,7 @@ class FontsController extends Controller
public function index(Request $request) public function index(Request $request)
{ {
return \Cache::remember('google_fonts', 60 * 60, function () { return \Cache::remember('google_fonts', 60 * 60, function () {
$url = "https://www.googleapis.com/webfonts/v1/webfonts?sort=popularity&key=" . config('services.google_fonts_api_key'); $url = "https://www.googleapis.com/webfonts/v1/webfonts?sort=popularity&key=" . config('services.google.fonts_api_key');
$response = Http::get($url); $response = Http::get($url);
if ($response->successful()) { if ($response->successful()) {
$fonts = collect($response->json()['items'])->filter(function ($font) { $fonts = collect($response->json()['items'])->filter(function ($font) {

View File

@ -7,5 +7,7 @@ use Laravel\Socialite\Contracts\User;
interface OAuthDriver interface OAuthDriver
{ {
public function getRedirectUrl(): string; public function getRedirectUrl(): string;
public function setRedirectUrl($url): self;
public function getUser(): User; public function getUser(): User;
public function canCreateUser(): bool;
} }

View File

@ -10,6 +10,8 @@ use Laravel\Socialite\Two\GoogleProvider;
class OAuthGoogleDriver implements OAuthDriver class OAuthGoogleDriver implements OAuthDriver
{ {
private ?string $redirectUrl = null;
protected GoogleProvider $provider; protected GoogleProvider $provider;
public function __construct() public function __construct()
@ -22,6 +24,7 @@ class OAuthGoogleDriver implements OAuthDriver
return $this->provider return $this->provider
->scopes([Sheets::DRIVE_FILE]) ->scopes([Sheets::DRIVE_FILE])
->stateless() ->stateless()
->redirectUrl($this->redirectUrl ?? config('services.google.redirect'))
->with([ ->with([
'access_type' => 'offline', 'access_type' => 'offline',
'prompt' => 'consent select_account' 'prompt' => 'consent select_account'
@ -34,6 +37,19 @@ class OAuthGoogleDriver implements OAuthDriver
{ {
return $this->provider return $this->provider
->stateless() ->stateless()
->redirectUrl($this->redirectUrl ?? config('services.google.redirect'))
->user(); ->user();
} }
public function canCreateUser(): bool
{
return true;
}
public function setRedirectUrl($url): OAuthDriver
{
$this->redirectUrl = $url;
return $this;
}
} }

View File

@ -30,7 +30,7 @@
/> />
<!-- Remember Me --> <!-- Remember Me -->
<div class="relative flex items-center my-5"> <div class="relative flex items-start mt-5">
<CheckboxInput <CheckboxInput
v-model="remember" v-model="remember"
class="w-full md:w-1/2" class="w-full md:w-1/2"
@ -52,15 +52,28 @@
<!-- Submit Button --> <!-- Submit Button -->
<v-button <v-button
dusk="btn_login" class="w-full flex"
:loading="form.busy || loading" :loading="form.busy || loading"
> >
Log in to continue Log in to continue
</v-button> </v-button>
<v-button
native-type="button"
color="white"
class="space-x-4 mt-4 flex items-center w-full"
:loading="false"
@click.prevent="signInwithGoogle"
>
<Icon
name="devicon:google"
class="w-4 h-4 -mt-1"
/>
<span class="mx-2">Sign in with Google</span>
</v-button>
<p <p
v-if="!appStore.selfHosted" v-if="!appStore.selfHosted"
class="text-gray-500 mt-4" class="text-gray-500 text-sm text-center mt-4"
> >
Don't have an account? Don't have an account?
<a <a
@ -106,6 +119,7 @@ export default {
authStore: useAuthStore(), authStore: useAuthStore(),
formsStore: useFormsStore(), formsStore: useFormsStore(),
workspaceStore: useWorkspacesStore(), workspaceStore: useWorkspacesStore(),
providersStore: useOAuthProvidersStore()
} }
}, },
@ -119,6 +133,8 @@ export default {
showForgotModal: false, showForgotModal: false,
}), }),
computed: {},
methods: { methods: {
login() { login() {
// Submit the form. // Submit the form.
@ -170,6 +186,9 @@ export default {
router.push({ name: "home" }) router.push({ name: "home" })
} }
}, },
signInwithGoogle() {
this.providersStore.guestConnect('google', true)
}
}, },
} }
</script> </script>

View File

@ -1,7 +1,6 @@
<template> <template>
<div> <div>
<form <form
class="mt-4"
@submit.prevent="register" @submit.prevent="register"
@keydown="form.onKeydown($event)" @keydown="form.onKeydown($event)"
> >
@ -56,6 +55,7 @@
<checkbox-input <checkbox-input
:form="form" :form="form"
name="agree_terms" name="agree_terms"
class="mb-3"
:required="true" :required="true"
> >
<template #label> <template #label>
@ -63,6 +63,7 @@
<NuxtLink <NuxtLink
:to="{ name: 'terms-conditions' }" :to="{ name: 'terms-conditions' }"
target="_blank" target="_blank"
class="underline"
> >
Terms and conditions Terms and conditions
</NuxtLink> </NuxtLink>
@ -70,6 +71,7 @@
<NuxtLink <NuxtLink
:to="{ name: 'privacy-policy' }" :to="{ name: 'privacy-policy' }"
target="_blank" target="_blank"
class="underline"
> >
Privacy policy Privacy policy
</NuxtLink> </NuxtLink>
@ -78,16 +80,33 @@
</checkbox-input> </checkbox-input>
<!-- Submit Button --> <!-- Submit Button -->
<v-button :loading="form.busy"> <v-button
Create an account class="w-full mt-4"
:loading="form.busy"
>
Create account
</v-button> </v-button>
<p class="text-gray-500 mt-4"> <p class="text-gray-600/50 text-sm text-center my-4">
Or
</p>
<v-button
native-type="buttom"
color="white"
class="space-x-4 flex items-center w-full"
:loading="false"
@click.prevent="signInwithGoogle"
>
<Icon name="devicon:google" />
<span class="mx-2">Sign in with Google</span>
</v-button>
<p class="text-gray-500 mt-4 text-sm text-center">
Already have an account? Already have an account?
<a <a
v-if="isQuick" v-if="isQuick"
href="#" href="#"
class="font-semibold ml-1" class="font-medium ml-1"
@click.prevent="$emit('openLogin')" @click.prevent="$emit('openLogin')"
>Log In</a> >Log In</a>
<NuxtLink <NuxtLink
@ -123,6 +142,7 @@ export default {
authStore: useAuthStore(), authStore: useAuthStore(),
formsStore: useFormsStore(), formsStore: useFormsStore(),
workspaceStore: useWorkspacesStore(), workspaceStore: useWorkspacesStore(),
providersStore: useOAuthProvidersStore(),
logEvent: useAmplitude().logEvent, logEvent: useAmplitude().logEvent,
} }
}, },
@ -241,6 +261,9 @@ export default {
} }
} }
}, },
signInwithGoogle() {
this.providersStore.guestConnect('google', true)
}
}, },
} }
</script> </script>

View File

@ -2,7 +2,6 @@ export default defineNuxtRouteMiddleware((from, to, next) => {
const runtimeConfig = useRuntimeConfig() const runtimeConfig = useRuntimeConfig()
const route = useRoute() const route = useRoute()
if (runtimeConfig.public?.selfHosted) { if (runtimeConfig.public?.selfHosted) {
console.log('in')
if (from.name === 'register' && route.query?.email && route.query?.invite_token) { if (from.name === 'register' && route.query?.email && route.query?.invite_token) {
return return
} }

View File

@ -9,7 +9,9 @@
<h2 class="font-semibold text-2xl"> <h2 class="font-semibold text-2xl">
Login to OpnForm Login to OpnForm
</h2> </h2>
<small>Welcome back! Please enter your details.</small> <p class="text-sm text-gray-500">
Welcome back! Please enter your details.
</p>
<login-form /> <login-form />
</div> </div>

View File

@ -0,0 +1,92 @@
<template>
<template>
<div class="flex flex-grow mt-6 mb-10">
<div class="w-full md:w-2/3 md:mx-auto md:max-w-md px-4">
<div class="m-10" v-if="loading">
<h3 class="my-6 text-center">
Please wait...
</h3>
<Loader class="h-6 w-6 mx-auto m-10" />
</div>
<div class="m-6 flex flex-col items-center space-y-4" v-else>
<p class="text-center"> Unable to sign it at the moment. </p>
<v-button
:to="{ name: 'login' }"
>Back to login</v-button>
</div>
</div>
</div>
</template>
</template>
<script setup>
const router = useRouter()
const route = useRoute()
const authStore = useAuthStore()
const workspacesStore = useWorkspacesStore()
const formsStore = useFormsStore()
const logEvent = useAmplitude().logEvent
const loading = ref(true);
definePageMeta({
alias: [
'/oauth/:provider/callback'
]
})
function handleCallback() {
const code = route.query.code
const provider = route.params.provider
opnFetch(`/oauth/${provider}/callback`, {
method: 'POST',
params: {
code
}
}).then(async (data) => {
authStore.setToken(data.token)
const [userDataResponse, workspacesResponse] = await Promise.all([
opnFetch("user"),
fetchAllWorkspaces(),
])
authStore.setUser(userDataResponse)
workspacesStore.set(workspacesResponse.data.value)
// Load forms
formsStore.loadAll(workspacesStore.currentId)
if (!data.new_user) {
logEvent("login", { source: provider })
try {
useGtm().trackEvent({
event: 'login',
source: provider
})
} catch (error) {
console.error(error)
}
router.push({ name: "home" })
return
} else {
logEvent("register", { source: provider })
router.push({ name: "forms-create" })
useAlert().success("Success! You're now registered with your Google account! Welcome to OpnForm.")
try {
useGtm().trackEvent({
event: 'register',
source: provider
})
} catch (error) {
console.error(error)
useAlert().error(error)
}
}
}).catch(error => {
useAlert().error(error.response._data.message)
loading.value = false;
})
}
onMounted(() => {
handleCallback()
})
</script>

View File

@ -10,7 +10,9 @@
<h2 class="font-semibold text-2xl"> <h2 class="font-semibold text-2xl">
Create an account Create an account
</h2> </h2>
<small>Sign up in less than 2 minutes.</small> <p class="text-gray-500 text-sm">
Sign up in less than 2 minutes.
</p>
<template v-if="!appStore.selfHosted || isInvited"> <template v-if="!appStore.selfHosted || isInvited">
<register-form /> <register-form />
</template> </template>

View File

@ -61,6 +61,33 @@ export const useOAuthProvidersStore = defineStore("oauth_providers", () => {
}) })
} }
const guestConnect = (service, redirect = false) => {
contentStore.resetState()
contentStore.startLoading()
const intention = new URL(window.location.href).pathname
opnFetch(`/oauth/connect/${service}`, {
method: 'POST',
body: {
...redirect ? { intention } : {},
}
})
.then((data) => {
window.location.href = data.url
})
.catch((error) => {
try {
alert.error(error.data.message)
} catch (e) {
alert.error("An error occurred while connecting an account")
}
})
.finally(() => {
contentStore.stopLoading()
})
}
const providers = computed(() => contentStore.getAll.value) const providers = computed(() => contentStore.getAll.value)
return { return {
@ -69,6 +96,7 @@ export const useOAuthProvidersStore = defineStore("oauth_providers", () => {
getService, getService,
fetchOAuthProviders, fetchOAuthProviders,
providers, providers,
connect connect,
guestConnect
} }
}) })

View File

@ -69,7 +69,9 @@ return [
'client_id' => env('GOOGLE_CLIENT_ID'), 'client_id' => env('GOOGLE_CLIENT_ID'),
'client_secret' => env('GOOGLE_CLIENT_SECRET'), 'client_secret' => env('GOOGLE_CLIENT_SECRET'),
'redirect' => env('GOOGLE_REDIRECT_URL'), 'redirect' => env('GOOGLE_REDIRECT_URL'),
'auth_redirect' => env('GOOGLE_AUTH_REDIRECT_URL'),
'fonts_api_key' => env('GOOGLE_FONTS_API_KEY'),
], ],
'google_fonts_api_key' => env('GOOGLE_FONTS_API_KEY'),
]; ];

View File

@ -269,8 +269,9 @@ Route::group(['middleware' => 'guest:api'], function () {
Route::post('email/verify/{user}', [VerificationController::class, 'verify'])->name('verification.verify'); Route::post('email/verify/{user}', [VerificationController::class, 'verify'])->name('verification.verify');
Route::post('email/resend', [VerificationController::class, 'resend']); Route::post('email/resend', [VerificationController::class, 'resend']);
Route::post('oauth/{driver}', [OAuthController::class, 'redirect']); Route::post('oauth/{provider}', [OAuthController::class, 'redirect']);
Route::get('oauth/{driver}/callback', [OAuthController::class, 'handleCallback'])->name('oauth.callback'); Route::post('oauth/connect/{provider}', [OAuthController::class, 'redirect'])->name('oauth.redirect');
Route::post('oauth/{provider}/callback', [OAuthController::class, 'handleCallback'])->name('oauth.callback');
}); });
Route::group(['prefix' => 'appsumo'], function () { Route::group(['prefix' => 'appsumo'], function () {