From a293584fc733b63428ef6cedb6f6fb04b0e3c198 Mon Sep 17 00:00:00 2001 From: Web Artisan Date: Sat, 19 Jul 2025 21:01:58 +0500 Subject: [PATCH 01/19] chore: change device detector --- app/Helpers/Utils.php | 30 ++++++++++++++++++++++++++++ app/Providers/AppServiceProvider.php | 11 +++++----- composer.json | 9 ++++++--- 3 files changed, 41 insertions(+), 9 deletions(-) create mode 100644 app/Helpers/Utils.php diff --git a/app/Helpers/Utils.php b/app/Helpers/Utils.php new file mode 100644 index 0000000..63a2ef8 --- /dev/null +++ b/app/Helpers/Utils.php @@ -0,0 +1,30 @@ +parse(); + + return $detector; + } + + /** + * Get device name from user agent + */ + public static function getDeviceNameFromDetector(DeviceDetector $device): string + { + return implode(' / ', array_filter([ + trim(implode(' ', [$device->getOs('name'), $device->getOs('version')])), + trim(implode(' ', [$device->getClient('name'), $device->getClient('version')])), + ])) ?? 'Unknown'; + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 1ab0ee1..f675de8 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -102,13 +102,12 @@ public function boot(): void return Str::replaceMatches('/[^\p{L}\d ]/u', '', $text); }); - Request::macro('deviceName', function (): string { - $device = $this->device(); + Request::macro('device', function () { + return Utils::getDeviceDetectorByUserAgent($this->userAgent()); + }); - return implode(' / ', array_filter([ - trim(implode(' ', [$device->getOs('name'), $device->getOs('version')])), - trim(implode(' ', [$device->getClient('name'), $device->getClient('version')])), - ])) ?? 'Unknown'; + Request::macro('deviceName', function (): string { + return Utils::getDeviceNameFromDetector($this->device()); }); Sanctum::usePersonalAccessTokenModel( diff --git a/composer.json b/composer.json index 55ee7ff..0aec89c 100644 --- a/composer.json +++ b/composer.json @@ -3,7 +3,10 @@ "name": "laravel/laravel", "type": "project", "description": "The skeleton application for the Laravel framework.", - "keywords": ["laravel", "framework"], + "keywords": [ + "laravel", + "framework" + ], "license": "MIT", "require": { "php": "^8.3", @@ -14,7 +17,7 @@ "laravel/socialite": "^5.12", "laravel/tinker": "^2.9", "league/flysystem-aws-s3-v3": "^3.24", - "reefki/laravel-device-detector": "^1.0", + "matomo/device-detector": "^6.4", "spatie/laravel-permission": "^6.4", "symfony/filesystem": "^7.2" }, @@ -79,4 +82,4 @@ }, "minimum-stability": "stable", "prefer-stable": true -} +} \ No newline at end of file From 65e254f15e9c2612315ea49bb7e224e45a8bac5f Mon Sep 17 00:00:00 2001 From: Web Artisan Date: Sat, 19 Jul 2025 23:11:43 +0500 Subject: [PATCH 02/19] refactor: session based auth --- .env.example | 4 +- app/Http/Controllers/AuthController.php | 66 +++++++++---------------- app/Models/PersonalAccessToken.php | 22 --------- app/Models/Session.php | 28 +++++++++++ app/Models/User.php | 22 ++------- app/Providers/AppServiceProvider.php | 6 --- bootstrap/app.php | 13 ++--- nuxt/components/auth/Login.vue | 4 -- nuxt/pages/account/devices.vue | 2 +- nuxt/plugins/app.ts | 20 ++------ nuxt/plugins/auth.ts | 10 +++- nuxt/stores/auth.ts | 4 +- routes/api.php | 2 +- 13 files changed, 79 insertions(+), 124 deletions(-) delete mode 100644 app/Models/PersonalAccessToken.php create mode 100644 app/Models/Session.php diff --git a/.env.example b/.env.example index 228e00d..b286c47 100755 --- a/.env.example +++ b/.env.example @@ -17,7 +17,7 @@ APP_LOCALE=en APP_FALLBACK_LOCALE=en APP_FAKER_LOCALE=en_US -AUTH_GUARD=api +AUTH_GUARD=web APP_MAINTENANCE_DRIVER=file APP_MAINTENANCE_STORE=database @@ -43,6 +43,8 @@ SESSION_LIFETIME=120 SESSION_ENCRYPT=false SESSION_PATH=/ SESSION_DOMAIN=null +SANCTUM_STATEFUL_DOMAINS=localhost +SESSION_SECURE_COOKIE=false BROADCAST_CONNECTION=log FILESYSTEM_DISK=local diff --git a/app/Http/Controllers/AuthController.php b/app/Http/Controllers/AuthController.php index d6e38b7..2592758 100755 --- a/app/Http/Controllers/AuthController.php +++ b/app/Http/Controllers/AuthController.php @@ -2,6 +2,7 @@ namespace App\Http\Controllers; +use App\Helpers\Utils; use App\Models\User; use App\Models\UserProvider; use Illuminate\Auth\Events\PasswordReset; @@ -10,7 +11,7 @@ use Illuminate\Http\JsonResponse; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; -use Illuminate\Support\Facades\Crypt; +use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Password; use Illuminate\Support\Str; @@ -112,23 +113,19 @@ public function callback(Request $request, string $provider): View $user = $userProvider->user; } - $token = $user->createDeviceToken( - device: $request->deviceName(), - ip: $request->ip(), - remember: true - ); + Auth::login($user, true); + $request->session()->regenerate(); return view('oauth', [ 'message' => [ 'ok' => true, 'provider' => $provider, - 'token' => $token, ], ]); } /** - * Generate sanctum token on successful login + * Login user * @throws ValidationException */ public function login(Request $request): JsonResponse @@ -138,23 +135,16 @@ public function login(Request $request): JsonResponse 'password' => ['required', 'string'], ]); - $user = User::select(['id', 'password'])->where('email', $request->email)->first(); - - if (!$user || !Hash::check($request->password, $user->password)) { + if (!Auth::attempt($request->only('email', 'password'), $request->remember)) { throw ValidationException::withMessages([ 'email' => __('auth.failed'), ]); } - $token = $user->createDeviceToken( - device: $request->deviceName(), - ip: $request->ip(), - remember: $request->input('remember', false) - ); + $request->session()->regenerate(); return response()->json([ 'ok' => true, - 'token' => $token, ]); } @@ -163,7 +153,7 @@ public function login(Request $request): JsonResponse */ public function logout(Request $request): JsonResponse { - $request->user()->currentAccessToken()->delete(); + Auth::logout(); return response()->json([ 'ok' => true, @@ -226,7 +216,7 @@ public function resetPassword(Request $request): JsonResponse { $request->validate([ 'token' => ['required'], - 'email' => ['required', 'email', 'exists:'.User::class], + 'email' => ['required', 'email', 'exists:' . User::class], 'password' => ['required', 'confirmed', Rules\Password::defaults()], ]); @@ -287,7 +277,7 @@ public function verificationNotification(Request $request): JsonResponse 'email' => ['required', 'email'], ]); - $user = $request->user()?: User::where('email', $request->email)->whereNull('email_verified_at')->first(); + $user = $request->user() ?: User::where('email', $request->email)->whereNull('email_verified_at')->first(); abort_if(!$user, 400); @@ -306,22 +296,19 @@ public function devices(Request $request): JsonResponse { $user = $request->user(); - $devices = $user->tokens() - ->select('id', 'name', 'ip', 'last_used_at') - ->orderBy('last_used_at', 'DESC') - ->get(); - - $currentToken = $user->currentAccessToken(); - - foreach ($devices as $device) { - $device->hash = Crypt::encryptString($device->id); + $currentSessionId = $request->session()->getId(); - if ($currentToken->id === $device->id) { - $device->is_current = true; - } + $devices = $user->sessions() + ->select(['id as key', 'ip_address as ip', 'user_agent as name', 'last_activity']) + ->orderBy('last_activity', 'DESC') + ->get() + ->map(function ($device) use ($currentSessionId) { + $device->is_current = $currentSessionId === $device->key; + $device->name = Utils::getDeviceNameFromDetector(Utils::getDeviceDetectorByUserAgent($device->name)); + $device->last_used_at = now()->parse($device->last_activity); - unset($device->id); - } + return $device; + }); return response()->json([ 'ok' => true, @@ -330,21 +317,16 @@ public function devices(Request $request): JsonResponse } /** - * Revoke token by id + * Disconnect device by id */ public function deviceDisconnect(Request $request): JsonResponse { $request->validate([ - 'hash' => 'required', + 'key' => 'required|size:40', ]); $user = $request->user(); - - $id = (int) Crypt::decryptString($request->hash); - - if (!empty($id)) { - $user->tokens()->where('id', $id)->delete(); - } + $user->sessions()->where('id', $request->key)->delete(); return response()->json([ 'ok' => true, diff --git a/app/Models/PersonalAccessToken.php b/app/Models/PersonalAccessToken.php deleted file mode 100644 index b6a7ac9..0000000 --- a/app/Models/PersonalAccessToken.php +++ /dev/null @@ -1,22 +0,0 @@ - $this->getOriginal('last_used_at') < now()->parse($value)->subMinute() - ? $value - : $this->getOriginal('last_used_at'), - ); - } -} diff --git a/app/Models/Session.php b/app/Models/Session.php new file mode 100644 index 0000000..f4e9c1a --- /dev/null +++ b/app/Models/Session.php @@ -0,0 +1,28 @@ + 'string', + 'last_activity' => 'integer', + ]; + + protected $hidden = [ + 'payload', + ]; + + public function user() + { + return $this->belongsTo(User::class); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 8fc044b..0b46d35 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -7,12 +7,11 @@ use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; -use Laravel\Sanctum\HasApiTokens; use Spatie\Permission\Traits\HasRoles; class User extends Authenticatable implements MustVerifyEmail { - use HasApiTokens, HasFactory, HasRoles, Notifiable; + use HasFactory, HasRoles, Notifiable; /** * The attributes that are mass assignable. @@ -56,24 +55,13 @@ public function userProviders(): HasMany return $this->hasMany(UserProvider::class); } - public function mustVerifyEmail(): bool + public function sessions(): HasMany { - return $this instanceof MustVerifyEmail && !$this->hasVerifiedEmail(); + return $this->hasMany(Session::class); } - public function createDeviceToken(string $device, string $ip, bool $remember = false): string + public function mustVerifyEmail(): bool { - $sanctumToken = $this->createToken( - $device, - ['*'], - $remember ? - now()->addMonth() : - now()->addDay() - ); - - $sanctumToken->accessToken->ip = $ip; - $sanctumToken->accessToken->save(); - - return $sanctumToken->plainTextToken; + return $this instanceof MustVerifyEmail && !$this->hasVerifiedEmail(); } } diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index f675de8..4a0fac0 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -3,7 +3,6 @@ namespace App\Providers; use App\Helpers\Image; -use App\Models\PersonalAccessToken; use Illuminate\Auth\Events\Lockout; use Illuminate\Auth\Notifications\ResetPassword; use Illuminate\Auth\Notifications\VerifyEmail; @@ -14,7 +13,6 @@ use Illuminate\Support\ServiceProvider; use Illuminate\Support\Str; use Illuminate\Validation\ValidationException; -use Laravel\Sanctum\Sanctum; class AppServiceProvider extends ServiceProvider { @@ -109,9 +107,5 @@ public function boot(): void Request::macro('deviceName', function (): string { return Utils::getDeviceNameFromDetector($this->device()); }); - - Sanctum::usePersonalAccessTokenModel( - PersonalAccessToken::class - ); } } diff --git a/bootstrap/app.php b/bootstrap/app.php index 88dd317..2066023 100755 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -8,23 +8,20 @@ use Illuminate\Http\Request; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Illuminate\Validation\ValidationException; +use Symfony\Component\HttpFoundation\IpUtils; return Application::configure(basePath: dirname(__DIR__)) ->withRouting( - api: __DIR__.'/../routes/api.php', + api: __DIR__ . '/../routes/api.php', apiPrefix: '', - commands: __DIR__.'/../routes/console.php', + commands: __DIR__ . '/../routes/console.php', health: '/up', ) ->withMiddleware(function (Middleware $middleware) { $middleware ->throttleApi(redis: true) - ->trustProxies(at: [ - '127.0.0.0/8', - '10.0.0.0/8', - '172.16.0.0/12', - '192.168.0.0/16', - ]) + ->trustProxies(at: IpUtils::PRIVATE_SUBNETS) + ->statefulApi() ->api(prepend: [ JsonResponse::class, ]); diff --git a/nuxt/components/auth/Login.vue b/nuxt/components/auth/Login.vue index 2b0480d..6334ed5 100644 --- a/nuxt/components/auth/Login.vue +++ b/nuxt/components/auth/Login.vue @@ -7,7 +7,6 @@ const router = useRouter(); const auth = useAuthStore(); const form = useTemplateRef>('form'); const toast = useToast(); -const nuxtApp = useNuxtApp(); const state = reactive({ email: "", @@ -24,8 +23,6 @@ const { refresh: onSubmit, status: loginStatus } = useHttp("login", { if (response?.status === 422) { form.value.setErrors(response._data?.errors); } else if (response._data?.ok) { - nuxtApp.$token.value = response._data.token; - await auth.fetchUser(); await router.push("/"); } @@ -39,7 +36,6 @@ async function handleMessage(event: { data: any }): Promise { if (Object.keys(providers.value).includes(provider) && event.data.token) { providers.value[provider].loading = false; - nuxtApp.$token.value = event.data.token; await auth.fetchUser(); await router.push("/"); diff --git a/nuxt/pages/account/devices.vue b/nuxt/pages/account/devices.vue index 42f7c09..7fa2461 100644 --- a/nuxt/pages/account/devices.vue +++ b/nuxt/pages/account/devices.vue @@ -31,7 +31,7 @@ const items = (row: any) => [ await $http("devices/disconnect", { method: "POST", body: { - hash: row.hash, + key: row.key, }, async onFetchResponse({ response }) { if (response._data?.ok) { diff --git a/nuxt/plugins/app.ts b/nuxt/plugins/app.ts index d2fd04a..1899518 100644 --- a/nuxt/plugins/app.ts +++ b/nuxt/plugins/app.ts @@ -14,14 +14,9 @@ async function callHooks(context, hooks) { export default defineNuxtPlugin((nuxtApp) => { const config = useRuntimeConfig(); const requestUrl = useRequestURL(); - const requestHeaders = useRequestHeaders(['x-forwarded-for', 'user-agent']); + const requestHeaders = useRequestHeaders(['cookie', 'x-forwarded-for', 'user-agent']); const toast = useToast(); - const token = useCookie('token', { - path: '/', - sameSite: 'strict', - secure: config.public.apiBase.startsWith('https://'), - maxAge: 60 * 60 * 24 * 365 - }); + const xsrfToken = useCookie('XSRF-TOKEN'); function storage(path: string): string { if (!path) return ''; @@ -33,7 +28,7 @@ export default defineNuxtPlugin((nuxtApp) => { function buildHeaders(headers: any): Headers { return { Accept: 'application/json', - Authorization: token.value ? `Bearer ${token.value}` : undefined, + 'X-XSRF-TOKEN': xsrfToken.value, ...headers, ...( import.meta.server @@ -103,14 +98,6 @@ export default defineNuxtPlugin((nuxtApp) => { if (context.response.status === 401) { const auth = useAuthStore(); auth.reset(); - - if (import.meta.client) { - toast.add({ - title: 'Please log in to continue', - icon: 'i-heroicons-exclamation-circle-solid', - color: 'warning' - }); - } } else if (context.response.status !== 422 && import.meta.client) { toast.add({ icon: 'i-heroicons-exclamation-circle-solid', @@ -124,7 +111,6 @@ export default defineNuxtPlugin((nuxtApp) => { return { provide: { storage, - token, http } } diff --git a/nuxt/plugins/auth.ts b/nuxt/plugins/auth.ts index 936312c..1dc67c9 100644 --- a/nuxt/plugins/auth.ts +++ b/nuxt/plugins/auth.ts @@ -1,7 +1,13 @@ export default defineNuxtPlugin(async (nuxtApp) => { const auth = useAuthStore(); + const config = useRuntimeConfig(); - if (auth.logged) { - await auth.fetchUser(); + if (import.meta.client) { + $fetch('/sanctum/csrf-cookie', { + baseURL: config.public.apiBase, + credentials: 'include', + }); } + + await auth.fetchUser(); }) diff --git a/nuxt/stores/auth.ts b/nuxt/stores/auth.ts index 6aa047c..5c4c0a3 100644 --- a/nuxt/stores/auth.ts +++ b/nuxt/stores/auth.ts @@ -12,9 +12,8 @@ export type User = { } export const useAuthStore = defineStore('auth', () => { - const nuxtApp = useNuxtApp(); const user = ref({}); - const logged = computed(() => !!nuxtApp.$token.value); + const logged = computed(() => !!user.value?.ulid); const { refresh: logout } = useHttp('logout', { method: 'POST', @@ -37,7 +36,6 @@ export const useAuthStore = defineStore('auth', () => { }); function reset(): void { - nuxtApp.$token.value = '' user.value = {} } diff --git a/routes/api.php b/routes/api.php index f554d5d..90fc1d1 100644 --- a/routes/api.php +++ b/routes/api.php @@ -19,7 +19,7 @@ Route::post('verification-notification', [AuthController::class, 'verificationNotification'])->middleware('throttle:verification-notification')->name('verification.send'); Route::get('verify-email/{ulid}/{hash}', [AuthController::class, 'verifyEmail'])->middleware(['signed', 'throttle:6,1'])->name('verification.verify'); - Route::middleware(['auth:sanctum'])->group(function () { + Route::middleware(['auth'])->group(function () { Route::post('logout', [AuthController::class, 'logout'])->name('logout'); Route::post('devices/disconnect', [AuthController::class, 'deviceDisconnect'])->name('devices.disconnect'); Route::get('devices', [AuthController::class, 'devices'])->name('devices'); From 6fe3356af759e6457aacd0a778504a346b97690c Mon Sep 17 00:00:00 2001 From: Web Artisan Date: Sun, 20 Jul 2025 21:39:48 +0500 Subject: [PATCH 03/19] refactor: rollback sanctum token --- app/Models/PersonalAccessToken.php | 22 ++++++++++++++++++++++ app/Models/User.php | 19 ++++++++++++++++++- 2 files changed, 40 insertions(+), 1 deletion(-) create mode 100644 app/Models/PersonalAccessToken.php diff --git a/app/Models/PersonalAccessToken.php b/app/Models/PersonalAccessToken.php new file mode 100644 index 0000000..f1281c2 --- /dev/null +++ b/app/Models/PersonalAccessToken.php @@ -0,0 +1,22 @@ + $this->getOriginal('last_used_at') < now()->parse($value)->subMinute() + ? $value + : $this->getOriginal('last_used_at'), + ); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 0b46d35..c6099f9 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -8,10 +8,11 @@ use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; use Spatie\Permission\Traits\HasRoles; +use Laravel\Sanctum\HasApiTokens; class User extends Authenticatable implements MustVerifyEmail { - use HasFactory, HasRoles, Notifiable; + use HasApiTokens, HasFactory, HasRoles, Notifiable; /** * The attributes that are mass assignable. @@ -64,4 +65,20 @@ public function mustVerifyEmail(): bool { return $this instanceof MustVerifyEmail && !$this->hasVerifiedEmail(); } + + public function createDeviceToken(string $device, string $ip, bool $remember = false): string + { + $sanctumToken = $this->createToken( + $device, + ['*'], + $remember ? + now()->addMonth() : + now()->addDay() + ); + + $sanctumToken->accessToken->ip = $ip; + $sanctumToken->accessToken->save(); + + return $sanctumToken->plainTextToken; + } } From a1e409193ca293197efc8122c105788ce4db4b13 Mon Sep 17 00:00:00 2001 From: Web Artisan Date: Sun, 20 Jul 2025 21:40:30 +0500 Subject: [PATCH 04/19] update session model --- app/Models/Session.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/Models/Session.php b/app/Models/Session.php index f4e9c1a..89c48b0 100644 --- a/app/Models/Session.php +++ b/app/Models/Session.php @@ -13,11 +13,11 @@ class Session extends Model public $incrementing = false; protected $casts = [ - 'payload' => 'string', 'last_activity' => 'integer', ]; protected $hidden = [ + 'user_id', 'payload', ]; From 348cf69382165e25503ffe3e291d45a618ac741e Mon Sep 17 00:00:00 2001 From: Web Artisan Date: Sun, 20 Jul 2025 21:41:38 +0500 Subject: [PATCH 05/19] rollback sanctum tokens --- app/Providers/AppServiceProvider.php | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 4a0fac0..66d7132 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -3,6 +3,7 @@ namespace App\Providers; use App\Helpers\Image; +use App\Helpers\Utils; use Illuminate\Auth\Events\Lockout; use Illuminate\Auth\Notifications\ResetPassword; use Illuminate\Auth\Notifications\VerifyEmail; @@ -13,6 +14,8 @@ use Illuminate\Support\ServiceProvider; use Illuminate\Support\Str; use Illuminate\Validation\ValidationException; +use App\Models\PersonalAccessToken; +use Laravel\Sanctum\Sanctum; class AppServiceProvider extends ServiceProvider { @@ -107,5 +110,9 @@ public function boot(): void Request::macro('deviceName', function (): string { return Utils::getDeviceNameFromDetector($this->device()); }); + + if (config('auth.defaults.guard') === 'api') { + Sanctum::usePersonalAccessTokenModel(PersonalAccessToken::class); + } } } From af3f5d406388007e20cb2680a1d0f776be6dc1b4 Mon Sep 17 00:00:00 2001 From: Web Artisan Date: Sun, 20 Jul 2025 21:42:33 +0500 Subject: [PATCH 06/19] stateful api comment --- bootstrap/app.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bootstrap/app.php b/bootstrap/app.php index 2066023..20dca78 100755 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -19,9 +19,10 @@ ) ->withMiddleware(function (Middleware $middleware) { $middleware + // If the guard is web, we will use the stateful api middleware + ->statefulApi() ->throttleApi(redis: true) ->trustProxies(at: IpUtils::PRIVATE_SUBNETS) - ->statefulApi() ->api(prepend: [ JsonResponse::class, ]); From 8a6b3bea116a57c98a190f3a54a6e3a35c6eb4e3 Mon Sep 17 00:00:00 2001 From: Web Artisan Date: Sun, 20 Jul 2025 21:43:14 +0500 Subject: [PATCH 07/19] auth contracts --- app/Contracts/AuthServiceContract.php | 15 +++++ app/Http/Controllers/AuthController.php | 65 ++++++++++--------- app/Providers/AuthServiceProvider.php | 17 +++++ app/Services/Auth/ApiAuthService.php | 82 ++++++++++++++++++++++++ app/Services/Auth/AuthServiceFactory.php | 19 ++++++ app/Services/Auth/WebAuthService.php | 66 +++++++++++++++++++ bootstrap/providers.php | 1 + 7 files changed, 234 insertions(+), 31 deletions(-) create mode 100644 app/Contracts/AuthServiceContract.php create mode 100644 app/Providers/AuthServiceProvider.php create mode 100644 app/Services/Auth/ApiAuthService.php create mode 100644 app/Services/Auth/AuthServiceFactory.php create mode 100644 app/Services/Auth/WebAuthService.php diff --git a/app/Contracts/AuthServiceContract.php b/app/Contracts/AuthServiceContract.php new file mode 100644 index 0000000..5621cc1 --- /dev/null +++ b/app/Contracts/AuthServiceContract.php @@ -0,0 +1,15 @@ +user; } - Auth::login($user, true); - $request->session()->regenerate(); + $message = [ + 'ok' => true, + 'provider' => $provider, + ]; + + // If the guard is web, we will use the default login process + if (config('auth.defaults.guard') === 'web') { + Auth::login($user, true); + $request->session()->regenerate(); + } else { + // If the guard is api, we will use the token based authentication + $token = $user->createDeviceToken( + device: $request->deviceName(), + ip: $request->ip(), + remember: $request->input('remember', false) + ); + + $message['token'] = $token; + } return view('oauth', [ - 'message' => [ - 'ok' => true, - 'provider' => $provider, - ], + 'message' => $message, ]); } @@ -135,17 +153,17 @@ public function login(Request $request): JsonResponse 'password' => ['required', 'string'], ]); - if (!Auth::attempt($request->only('email', 'password'), $request->remember)) { + $user = User::select(['id', 'password'])->where('email', $request->email)->first(); + + if (!$user) { throw ValidationException::withMessages([ 'email' => __('auth.failed'), ]); } - $request->session()->regenerate(); + $result = $this->authService->login($request, $user); - return response()->json([ - 'ok' => true, - ]); + return response()->json($result); } /** @@ -153,7 +171,7 @@ public function login(Request $request): JsonResponse */ public function logout(Request $request): JsonResponse { - Auth::logout(); + $this->authService->logout($request); return response()->json([ 'ok' => true, @@ -294,21 +312,7 @@ public function verificationNotification(Request $request): JsonResponse */ public function devices(Request $request): JsonResponse { - $user = $request->user(); - - $currentSessionId = $request->session()->getId(); - - $devices = $user->sessions() - ->select(['id as key', 'ip_address as ip', 'user_agent as name', 'last_activity']) - ->orderBy('last_activity', 'DESC') - ->get() - ->map(function ($device) use ($currentSessionId) { - $device->is_current = $currentSessionId === $device->key; - $device->name = Utils::getDeviceNameFromDetector(Utils::getDeviceDetectorByUserAgent($device->name)); - $device->last_used_at = now()->parse($device->last_activity); - - return $device; - }); + $devices = $this->authService->getDevices($request); return response()->json([ 'ok' => true, @@ -322,11 +326,10 @@ public function devices(Request $request): JsonResponse public function deviceDisconnect(Request $request): JsonResponse { $request->validate([ - 'key' => 'required|size:40', + 'key' => 'required|string', ]); - $user = $request->user(); - $user->sessions()->where('id', $request->key)->delete(); + $this->authService->disconnectDevice($request); return response()->json([ 'ok' => true, diff --git a/app/Providers/AuthServiceProvider.php b/app/Providers/AuthServiceProvider.php new file mode 100644 index 0000000..c4d2483 --- /dev/null +++ b/app/Providers/AuthServiceProvider.php @@ -0,0 +1,17 @@ +app->bind(AuthServiceContract::class, function () { + return AuthServiceFactory::create(); + }); + } +} diff --git a/app/Services/Auth/ApiAuthService.php b/app/Services/Auth/ApiAuthService.php new file mode 100644 index 0000000..5d69b3a --- /dev/null +++ b/app/Services/Auth/ApiAuthService.php @@ -0,0 +1,82 @@ +password, $user->password)) { + throw ValidationException::withMessages([ + 'email' => __('auth.failed'), + ]); + } + + $token = $user->createDeviceToken( + device: $request->deviceName(), + ip: $request->ip(), + remember: $request->input('remember', false) + ); + + return [ + 'ok' => true, + 'token' => $token, + ]; + } + + public function logout(Request $request): void + { + $request->user()->currentAccessToken()->delete(); + } + + public function handleCallback(Request $request, User $user): array + { + $token = $user->createDeviceToken( + device: $request->deviceName(), + ip: $request->ip(), + remember: $request->input('remember', false) + ); + + return [ + 'ok' => true, + 'provider' => $request->route('provider'), + 'token' => $token, + ]; + } + + public function getDevices(Request $request): array + { + $user = $request->user(); + $currentToken = $user->currentAccessToken(); + + $devices = $user->tokens() + ->select('id', 'name', 'ip', 'last_used_at') + ->orderBy('last_used_at', 'DESC') + ->get() + ->map(function ($device) use ($currentToken) { + $device->key = Crypt::encryptString($device->id); + $device->is_current = $currentToken->id === $device->id; + unset($device->id); + + return $device; + }); + + return $devices->toArray(); + } + + public function disconnectDevice(Request $request): void + { + $id = (int) Crypt::decryptString($request->key); + + if (!empty($id)) { + $request->user()->tokens()->where('id', $id)->delete(); + } + } +} diff --git a/app/Services/Auth/AuthServiceFactory.php b/app/Services/Auth/AuthServiceFactory.php new file mode 100644 index 0000000..f49988b --- /dev/null +++ b/app/Services/Auth/AuthServiceFactory.php @@ -0,0 +1,19 @@ + new WebAuthService(), + 'api' => new ApiAuthService(), + default => throw new \InvalidArgumentException("Unsupported guard: {$guard}"), + }; + } +} diff --git a/app/Services/Auth/WebAuthService.php b/app/Services/Auth/WebAuthService.php new file mode 100644 index 0000000..251799c --- /dev/null +++ b/app/Services/Auth/WebAuthService.php @@ -0,0 +1,66 @@ +only('email', 'password'), $request->remember)) { + throw ValidationException::withMessages([ + 'email' => __('auth.failed'), + ]); + } + + $request->session()->regenerate(); + + return ['ok' => true]; + } + + public function logout(Request $request): void + { + Auth::logout(); + } + + public function handleCallback(Request $request, User $user): array + { + Auth::login($user, true); + $request->session()->regenerate(); + + return [ + 'ok' => true, + 'provider' => $request->route('provider'), + ]; + } + + public function getDevices(Request $request): array + { + $user = $request->user(); + $currentSessionId = $request->session()->getId(); + + $devices = $user->sessions() + ->select(['id as key', 'ip_address as ip', 'user_agent as name', 'last_activity']) + ->orderBy('last_activity', 'DESC') + ->get() + ->map(function ($device) use ($currentSessionId) { + $device->is_current = $currentSessionId === $device->key; + $device->name = Utils::getDeviceNameFromDetector(Utils::getDeviceDetectorByUserAgent($device->name)); + $device->last_used_at = now()->parse($device->last_activity); + + return $device; + }); + + return $devices->toArray(); + } + + public function disconnectDevice(Request $request): void + { + $request->user()->sessions()->where('id', $request->key)->delete(); + } +} diff --git a/bootstrap/providers.php b/bootstrap/providers.php index 002013b..48faffe 100644 --- a/bootstrap/providers.php +++ b/bootstrap/providers.php @@ -2,5 +2,6 @@ return [ App\Providers\AppServiceProvider::class, + App\Providers\AuthServiceProvider::class, Spatie\Permission\PermissionServiceProvider::class, ]; From 30b429f9d72c3cede4055419a518b2e211d29b8e Mon Sep 17 00:00:00 2001 From: Web Artisan Date: Sun, 20 Jul 2025 21:45:06 +0500 Subject: [PATCH 08/19] refactor: implement auth guard-specific routing --- routes/api.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/routes/api.php b/routes/api.php index 90fc1d1..30b0bba 100644 --- a/routes/api.php +++ b/routes/api.php @@ -11,7 +11,7 @@ Route::prefix('api/v1')->group(function () { Route::get('login/{provider}/redirect', [AuthController::class, 'redirect'])->name('login.provider.redirect'); - Route::get('login/{provider}/callback', [AuthController::class, 'callback'])->name('login.provider.callback'); + Route::get('login/{provider}/callback', [AuthController::class, 'callback'])->middleware(config('auth.defaults.guard'))->name('login.provider.callback'); Route::post('login', [AuthController::class, 'login'])->middleware('throttle:login')->name('login'); Route::post('register', [AuthController::class, 'register'])->name('register'); Route::post('forgot-password', [AuthController::class, 'sendResetPasswordLink'])->middleware('throttle:5,1')->name('password.email'); @@ -19,7 +19,7 @@ Route::post('verification-notification', [AuthController::class, 'verificationNotification'])->middleware('throttle:verification-notification')->name('verification.send'); Route::get('verify-email/{ulid}/{hash}', [AuthController::class, 'verifyEmail'])->middleware(['signed', 'throttle:6,1'])->name('verification.verify'); - Route::middleware(['auth'])->group(function () { + Route::middleware(["auth:" . config('auth.defaults.guard')])->group(function () { Route::post('logout', [AuthController::class, 'logout'])->name('logout'); Route::post('devices/disconnect', [AuthController::class, 'deviceDisconnect'])->name('devices.disconnect'); Route::get('devices', [AuthController::class, 'devices'])->name('devices'); From b8682014a3c30ba3f765aeea870f207b3065fc98 Mon Sep 17 00:00:00 2001 From: Web Artisan Date: Sun, 20 Jul 2025 21:45:45 +0500 Subject: [PATCH 09/19] feat: artisan auth:switch command --- app/Console/Commands/AuthSwitch.php | 78 +++++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 app/Console/Commands/AuthSwitch.php diff --git a/app/Console/Commands/AuthSwitch.php b/app/Console/Commands/AuthSwitch.php new file mode 100644 index 0000000..7068993 --- /dev/null +++ b/app/Console/Commands/AuthSwitch.php @@ -0,0 +1,78 @@ + 'API - Token based' . ($currentGuard === 'api' ? ' (Current)' : ''), + 'web' => 'Web - Session based' . ($currentGuard === 'web' ? ' (Current)' : ''), + ], + ); + + if ($currentGuard === $guard) { + $this->info('Authentication guard is already ' . $guard . '!'); + return; + } + + $this->info('Switching authentication guard to ' . $guard . '...'); + + Env::writeVariable('AUTH_GUARD', $guard, base_path('.env'), true); + + if ($guard === 'web') { + $this->replaceByPattern(base_path('bootstrap/app.php'), '->statefulApi()', true); + } else if ($guard === 'api') { + $this->replaceByPattern(base_path('bootstrap/app.php'), '->statefulApi()', false); + } + + Artisan::call('optimize'); + + $this->info('Authentication guard switched to ' . $guard . ' successfully!'); + } + + private function replaceByPattern(string $path, string $pattern, bool $enable): void + { + $content = $contentReplaced = file_get_contents($path); + + if (!preg_match('@' . preg_quote($pattern) . '@', $content)) { + $this->fail('Pattern not found in ' . $path); + } + + if ($enable) { + $contentReplaced = preg_replace('@([/]+[ ]*)?' . preg_quote($pattern) . '@', $pattern, $content); + } else { + $contentReplaced = preg_replace('@([/]+[ ]*)?' . preg_quote($pattern) . '@', '//' . $pattern, $content); + } + + if ($contentReplaced !== $content) { + file_put_contents($path, $contentReplaced); + } + } +} From 86aa4ebeb7391cc4072c315a743c034d940421a8 Mon Sep 17 00:00:00 2001 From: Web Artisan Date: Sun, 20 Jul 2025 21:48:05 +0500 Subject: [PATCH 10/19] rafactor: fetch, storage helper --- nuxt/index.d.ts | 1 - nuxt/utils/helpers.ts | 18 ++++++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) create mode 100644 nuxt/utils/helpers.ts diff --git a/nuxt/index.d.ts b/nuxt/index.d.ts index de2ba80..30c63fe 100644 --- a/nuxt/index.d.ts +++ b/nuxt/index.d.ts @@ -5,7 +5,6 @@ import type { $Fetch } from 'nitropack/types'; declare module '#app' { interface NuxtApp { - $storage(msg: string): string; $http( request: NitroFetchRequest, opts?: HttpFetchOptions diff --git a/nuxt/utils/helpers.ts b/nuxt/utils/helpers.ts new file mode 100644 index 0000000..46d02d5 --- /dev/null +++ b/nuxt/utils/helpers.ts @@ -0,0 +1,18 @@ +import type { NitroFetchRequest } from 'nitropack/types'; +import type { HttpFetchOptions } from '~'; + +export function $http(request: NitroFetchRequest, opts?: HttpFetchOptions): Promise { + const { $http: http } = useNuxtApp(); + + return http(request, opts); +} + +export function $storage(path: string): string { + if (!path) return ''; + + const config = useRuntimeConfig(); + + return path.startsWith('http://') || path.startsWith('https://') + ? path + : config.public.storageBase + path; +} From 021030825b756ce21281251a7ea011e504f8858b Mon Sep 17 00:00:00 2001 From: Web Artisan Date: Sun, 20 Jul 2025 21:55:38 +0500 Subject: [PATCH 11/19] chore: update fetch/storage usages --- nuxt/components/app/Header.vue | 1 - nuxt/components/input/UploadImage.vue | 2 -- nuxt/composables/useHttp.ts | 2 +- nuxt/composables/useLazyHttp.ts | 2 +- nuxt/pages/account/devices.vue | 1 - 5 files changed, 2 insertions(+), 6 deletions(-) diff --git a/nuxt/components/app/Header.vue b/nuxt/components/app/Header.vue index da22e00..7a338af 100644 --- a/nuxt/components/app/Header.vue +++ b/nuxt/components/app/Header.vue @@ -1,6 +1,5 @@