Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -43,6 +43,8 @@ SESSION_LIFETIME=120
SESSION_ENCRYPT=false
SESSION_PATH=/
SESSION_DOMAIN=null
SANCTUM_STATEFUL_DOMAINS=localhost,localhost:3000,127.0.0.1,127.0.0.1:8000,::1
SESSION_SECURE_COOKIE=false

BROADCAST_CONNECTION=log
FILESYSTEM_DISK=local
Expand Down
20 changes: 14 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ The goal of the project is to create a template for development on Laravel and N
- [Installation](#installation)
- [Standalone](#standalone)
- [Docker Deploy (Laravel Sail)](#docker-deploy-laravel-sail)
- [Auth Guard Switch](#auth-guard-switch)
- [Upgrade](#upgrade)
- [Usage](#usage)
- [Fetch wrapper](#fetch-wrapper)
Expand All @@ -36,7 +37,7 @@ The goal of the project is to create a template for development on Laravel and N
- [**Laravel 12**](https://laravel.com/docs/12.x) and [**Nuxt 4**](https://nuxt.com/)
- [**Laravel Octane**](https://laravel.com/docs/12.x/octane) supercharges your application's performance by serving your application using high-powered application servers.
- [**Laravel Telescope**](https://laravel.com/docs/12.x/telescope) provides insight into the requests coming into your application, exceptions, log entries, database queries, queued jobs, mail, notifications, cache operations, scheduled tasks, variable dumps, and more.
- [**Laravel Sanctum**](https://laravel.com/docs/12.x/sanctum) Token-based authorization is compatible with **SSR** and **CSR**
- [**Laravel Sanctum**](https://laravel.com/docs/12.x/sanctum) Token/Session-based authorization is compatible with **SSR** and **CSR**
- [**Laravel Socialite**](https://laravel.com/docs/12.x/socialite) OAuth providers
- [**Laravel Sail**](https://laravel.com/docs/12.x/sail) Light-weight command-line interface for interacting with Laravel's default Docker development environment.
- [**Spatie Laravel Permissions**](https://spatie.be/docs/laravel-permission/v6/introduction) This package allows you to manage user permissions and roles in a database.
Expand Down Expand Up @@ -89,6 +90,14 @@ To make sure this is always available, you may add this to your shell configurat

> Read the full [Laravel Sail](https://laravel.com/docs/12.x/sail) documentation to get the best user experience

### Auth Guard Switch

You can switch the authentication guard between **Token** and **Session** using the following command:

```shell
php artisan auth:switch
```

## Upgrade

Standalone:
Expand Down Expand Up @@ -117,7 +126,6 @@ Additionally, `$http` predefines a base url, authorization headers, and proxy IP
For example, the code for authorizing a user by email and password:
```vue
<script lang="ts" setup>
const nuxtApp = useNuxtApp();
const router = useRouter();
const auth = useAuthStore();
const form = templateRef("form");
Expand All @@ -136,9 +144,7 @@ const { refresh: onSubmit, status } = 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 auth.login(response._data.token ?? null);
await router.push("/");
}
}
Expand Down Expand Up @@ -182,8 +188,10 @@ const loading = computed(() => status.value === "pending");
Data returned by **useAuthStore**:
* `logged`: Boolean, whether the user is authorized
* `user`: User object, user stored in pinia store
* `logout`: Function, remove local data and call API to remove token
* `fetchCsrf`: Function, fetch csrf token
* `fetchUser`: Function, fetch user data
* `login`: Function, login user by token/session
* `logout`: Function, remove local data and call API to remove token/session
* `hasRole`: Function, checks the role

### Nuxt Middleware
Expand Down
82 changes: 82 additions & 0 deletions app/Console/Commands/AuthSwitch.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
<?php

namespace App\Console\Commands;

use Illuminate\Console\Command;
use Illuminate\Support\Env;
use Illuminate\Support\Facades\Artisan;
use Spatie\Permission\Models\Role;

use function Laravel\Prompts\select;

class AuthSwitch extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'auth:switch';

/**
* The console command description.
*
* @var string
*/
protected $description = 'Switch authentication guard';

/**
* Execute the console command.
*/
public function handle()
{
$currentGuard = config('auth.defaults.guard');
$guard = select(
label: 'Select the authentication guard',
options: [
'api' => '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);

Role::query()->update(['guard_name' => $guard]);

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);
}
}
}
15 changes: 15 additions & 0 deletions app/Contracts/AuthServiceContract.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?php

namespace App\Contracts;

use App\Models\User;
use Illuminate\Http\Request;

interface AuthServiceContract
{
public function login(Request $request, User $user): array;
public function logout(Request $request): void;
public function handleCallback(Request $request, User $user): array;
public function getDevices(Request $request): array;
public function disconnectDevice(Request $request): void;
}
30 changes: 30 additions & 0 deletions app/Helpers/Utils.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<?php

namespace App\Helpers;

use DeviceDetector\DeviceDetector;

class Utils
{
public static function getDeviceDetectorByUserAgent(string $userAgent): DeviceDetector
{
$detector = new DeviceDetector(
userAgent: $userAgent,
);

$detector->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';
}
}
89 changes: 37 additions & 52 deletions app/Http/Controllers/AuthController.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,17 +10,22 @@
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;
use Illuminate\Validation\Rules;
use Illuminate\Validation\ValidationException;
use Illuminate\View\View;
use Laravel\Socialite\Facades\Socialite;
use App\Contracts\AuthServiceContract;

class AuthController extends Controller
{
public function __construct(
private AuthServiceContract $authService
) {}

/**
* Register new user
*/
Expand Down Expand Up @@ -112,23 +117,33 @@ public function callback(Request $request, string $provider): View
$user = $userProvider->user;
}

$token = $user->createDeviceToken(
device: $request->deviceName(),
ip: $request->ip(),
remember: true
);
$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,
'token' => $token,
],
'message' => $message,
]);
}

/**
* Generate sanctum token on successful login
* Login user
* @throws ValidationException
*/
public function login(Request $request): JsonResponse
Expand All @@ -140,30 +155,23 @@ public function login(Request $request): JsonResponse

$user = User::select(['id', 'password'])->where('email', $request->email)->first();

if (!$user || !Hash::check($request->password, $user->password)) {
if (!$user) {
throw ValidationException::withMessages([
'email' => __('auth.failed'),
]);
}

$token = $user->createDeviceToken(
device: $request->deviceName(),
ip: $request->ip(),
remember: $request->input('remember', false)
);
$result = $this->authService->login($request, $user);

return response()->json([
'ok' => true,
'token' => $token,
]);
return response()->json($result);
}

/**
* Revoke token; only remove token that is used to perform logout (i.e. will not revoke all tokens)
*/
public function logout(Request $request): JsonResponse
{
$request->user()->currentAccessToken()->delete();
$this->authService->logout($request);

return response()->json([
'ok' => true,
Expand Down Expand Up @@ -226,7 +234,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()],
]);

Expand Down Expand Up @@ -287,7 +295,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);

Expand All @@ -304,24 +312,7 @@ public function verificationNotification(Request $request): JsonResponse
*/
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);

if ($currentToken->id === $device->id) {
$device->is_current = true;
}

unset($device->id);
}
$devices = $this->authService->getDevices($request);

return response()->json([
'ok' => true,
Expand All @@ -330,21 +321,15 @@ 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|string',
]);

$user = $request->user();

$id = (int) Crypt::decryptString($request->hash);

if (!empty($id)) {
$user->tokens()->where('id', $id)->delete();
}
$this->authService->disconnectDevice($request);

return response()->json([
'ok' => true,
Expand Down
2 changes: 1 addition & 1 deletion app/Models/PersonalAccessToken.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ class PersonalAccessToken extends SanctumPersonalAccessToken
protected function lastUsedAt(): Attribute
{
return Attribute::make(
set: fn (string $value) => $this->getOriginal('last_used_at') < now()->parse($value)->subMinute()
set: fn(string $value) => $this->getOriginal('last_used_at') < now()->parse($value)->subMinute()
? $value
: $this->getOriginal('last_used_at'),
);
Expand Down
Loading