Skip to content

Commit fcf8009

Browse files
authored
Merge pull request #23 from k2so-dev/session-based-auth
Session based auth
2 parents de00c6f + b9aab84 commit fcf8009

34 files changed

+571
-167
lines changed

.env.example

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ APP_LOCALE=en
1717
APP_FALLBACK_LOCALE=en
1818
APP_FAKER_LOCALE=en_US
1919

20-
AUTH_GUARD=api
20+
AUTH_GUARD=web
2121

2222
APP_MAINTENANCE_DRIVER=file
2323
APP_MAINTENANCE_STORE=database
@@ -43,6 +43,8 @@ SESSION_LIFETIME=120
4343
SESSION_ENCRYPT=false
4444
SESSION_PATH=/
4545
SESSION_DOMAIN=null
46+
SANCTUM_STATEFUL_DOMAINS=localhost,localhost:3000,127.0.0.1,127.0.0.1:8000,::1
47+
SESSION_SECURE_COOKIE=false
4648

4749
BROADCAST_CONNECTION=log
4850
FILESYSTEM_DISK=local

README.md

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ The goal of the project is to create a template for development on Laravel and N
1717
- [Installation](#installation)
1818
- [Standalone](#standalone)
1919
- [Docker Deploy (Laravel Sail)](#docker-deploy-laravel-sail)
20+
- [Auth Guard Switch](#auth-guard-switch)
2021
- [Upgrade](#upgrade)
2122
- [Usage](#usage)
2223
- [Fetch wrapper](#fetch-wrapper)
@@ -36,7 +37,7 @@ The goal of the project is to create a template for development on Laravel and N
3637
- [**Laravel 12**](https://laravel.com/docs/12.x) and [**Nuxt 4**](https://nuxt.com/)
3738
- [**Laravel Octane**](https://laravel.com/docs/12.x/octane) supercharges your application's performance by serving your application using high-powered application servers.
3839
- [**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.
39-
- [**Laravel Sanctum**](https://laravel.com/docs/12.x/sanctum) Token-based authorization is compatible with **SSR** and **CSR**
40+
- [**Laravel Sanctum**](https://laravel.com/docs/12.x/sanctum) Token/Session-based authorization is compatible with **SSR** and **CSR**
4041
- [**Laravel Socialite**](https://laravel.com/docs/12.x/socialite) OAuth providers
4142
- [**Laravel Sail**](https://laravel.com/docs/12.x/sail) Light-weight command-line interface for interacting with Laravel's default Docker development environment.
4243
- [**Spatie Laravel Permissions**](https://spatie.be/docs/laravel-permission/v6/introduction) This package allows you to manage user permissions and roles in a database.
@@ -89,6 +90,14 @@ To make sure this is always available, you may add this to your shell configurat
8990

9091
> Read the full [Laravel Sail](https://laravel.com/docs/12.x/sail) documentation to get the best user experience
9192
93+
### Auth Guard Switch
94+
95+
You can switch the authentication guard between **Token** and **Session** using the following command:
96+
97+
```shell
98+
php artisan auth:switch
99+
```
100+
92101
## Upgrade
93102

94103
Standalone:
@@ -117,7 +126,6 @@ Additionally, `$http` predefines a base url, authorization headers, and proxy IP
117126
For example, the code for authorizing a user by email and password:
118127
```vue
119128
<script lang="ts" setup>
120-
const nuxtApp = useNuxtApp();
121129
const router = useRouter();
122130
const auth = useAuthStore();
123131
const form = templateRef("form");
@@ -136,9 +144,7 @@ const { refresh: onSubmit, status } = useHttp("login", {
136144
if (response?.status === 422) {
137145
form.value.setErrors(response._data?.errors);
138146
} else if (response._data?.ok) {
139-
nuxtApp.$token.value = response._data.token;
140-
141-
await auth.fetchUser();
147+
await auth.login(response._data.token ?? null);
142148
await router.push("/");
143149
}
144150
}
@@ -182,8 +188,10 @@ const loading = computed(() => status.value === "pending");
182188
Data returned by **useAuthStore**:
183189
* `logged`: Boolean, whether the user is authorized
184190
* `user`: User object, user stored in pinia store
185-
* `logout`: Function, remove local data and call API to remove token
191+
* `fetchCsrf`: Function, fetch csrf token
186192
* `fetchUser`: Function, fetch user data
193+
* `login`: Function, login user by token/session
194+
* `logout`: Function, remove local data and call API to remove token/session
187195
* `hasRole`: Function, checks the role
188196

189197
### Nuxt Middleware

app/Console/Commands/AuthSwitch.php

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
<?php
2+
3+
namespace App\Console\Commands;
4+
5+
use Illuminate\Console\Command;
6+
use Illuminate\Support\Env;
7+
use Illuminate\Support\Facades\Artisan;
8+
use Spatie\Permission\Models\Role;
9+
10+
use function Laravel\Prompts\select;
11+
12+
class AuthSwitch extends Command
13+
{
14+
/**
15+
* The name and signature of the console command.
16+
*
17+
* @var string
18+
*/
19+
protected $signature = 'auth:switch';
20+
21+
/**
22+
* The console command description.
23+
*
24+
* @var string
25+
*/
26+
protected $description = 'Switch authentication guard';
27+
28+
/**
29+
* Execute the console command.
30+
*/
31+
public function handle()
32+
{
33+
$currentGuard = config('auth.defaults.guard');
34+
$guard = select(
35+
label: 'Select the authentication guard',
36+
options: [
37+
'api' => 'API - Token based' . ($currentGuard === 'api' ? ' (Current)' : ''),
38+
'web' => 'Web - Session based' . ($currentGuard === 'web' ? ' (Current)' : ''),
39+
],
40+
);
41+
42+
if ($currentGuard === $guard) {
43+
$this->info('Authentication guard is already ' . $guard . '!');
44+
return;
45+
}
46+
47+
$this->info('Switching authentication guard to ' . $guard . '...');
48+
49+
Env::writeVariable('AUTH_GUARD', $guard, base_path('.env'), true);
50+
51+
Role::query()->update(['guard_name' => $guard]);
52+
53+
if ($guard === 'web') {
54+
$this->replaceByPattern(base_path('bootstrap/app.php'), '->statefulApi()', true);
55+
} else if ($guard === 'api') {
56+
$this->replaceByPattern(base_path('bootstrap/app.php'), '->statefulApi()', false);
57+
}
58+
59+
Artisan::call('optimize');
60+
61+
$this->info('Authentication guard switched to ' . $guard . ' successfully!');
62+
}
63+
64+
private function replaceByPattern(string $path, string $pattern, bool $enable): void
65+
{
66+
$content = $contentReplaced = file_get_contents($path);
67+
68+
if (!preg_match('@' . preg_quote($pattern) . '@', $content)) {
69+
$this->fail('Pattern not found in ' . $path);
70+
}
71+
72+
if ($enable) {
73+
$contentReplaced = preg_replace('@([/]+[ ]*)?' . preg_quote($pattern) . '@', $pattern, $content);
74+
} else {
75+
$contentReplaced = preg_replace('@([/]+[ ]*)?' . preg_quote($pattern) . '@', '//' . $pattern, $content);
76+
}
77+
78+
if ($contentReplaced !== $content) {
79+
file_put_contents($path, $contentReplaced);
80+
}
81+
}
82+
}

app/Contracts/AuthServiceContract.php

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
<?php
2+
3+
namespace App\Contracts;
4+
5+
use App\Models\User;
6+
use Illuminate\Http\Request;
7+
8+
interface AuthServiceContract
9+
{
10+
public function login(Request $request, User $user): array;
11+
public function logout(Request $request): void;
12+
public function handleCallback(Request $request, User $user): array;
13+
public function getDevices(Request $request): array;
14+
public function disconnectDevice(Request $request): void;
15+
}

app/Helpers/Utils.php

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
<?php
2+
3+
namespace App\Helpers;
4+
5+
use DeviceDetector\DeviceDetector;
6+
7+
class Utils
8+
{
9+
public static function getDeviceDetectorByUserAgent(string $userAgent): DeviceDetector
10+
{
11+
$detector = new DeviceDetector(
12+
userAgent: $userAgent,
13+
);
14+
15+
$detector->parse();
16+
17+
return $detector;
18+
}
19+
20+
/**
21+
* Get device name from user agent
22+
*/
23+
public static function getDeviceNameFromDetector(DeviceDetector $device): string
24+
{
25+
return implode(' / ', array_filter([
26+
trim(implode(' ', [$device->getOs('name'), $device->getOs('version')])),
27+
trim(implode(' ', [$device->getClient('name'), $device->getClient('version')])),
28+
])) ?? 'Unknown';
29+
}
30+
}

app/Http/Controllers/AuthController.php

Lines changed: 37 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -10,17 +10,22 @@
1010
use Illuminate\Http\JsonResponse;
1111
use Illuminate\Http\RedirectResponse;
1212
use Illuminate\Http\Request;
13-
use Illuminate\Support\Facades\Crypt;
13+
use Illuminate\Support\Facades\Auth;
1414
use Illuminate\Support\Facades\Hash;
1515
use Illuminate\Support\Facades\Password;
1616
use Illuminate\Support\Str;
1717
use Illuminate\Validation\Rules;
1818
use Illuminate\Validation\ValidationException;
1919
use Illuminate\View\View;
2020
use Laravel\Socialite\Facades\Socialite;
21+
use App\Contracts\AuthServiceContract;
2122

2223
class AuthController extends Controller
2324
{
25+
public function __construct(
26+
private AuthServiceContract $authService
27+
) {}
28+
2429
/**
2530
* Register new user
2631
*/
@@ -112,23 +117,33 @@ public function callback(Request $request, string $provider): View
112117
$user = $userProvider->user;
113118
}
114119

115-
$token = $user->createDeviceToken(
116-
device: $request->deviceName(),
117-
ip: $request->ip(),
118-
remember: true
119-
);
120+
$message = [
121+
'ok' => true,
122+
'provider' => $provider,
123+
];
124+
125+
// If the guard is web, we will use the default login process
126+
if (config('auth.defaults.guard') === 'web') {
127+
Auth::login($user, true);
128+
$request->session()->regenerate();
129+
} else {
130+
// If the guard is api, we will use the token based authentication
131+
$token = $user->createDeviceToken(
132+
device: $request->deviceName(),
133+
ip: $request->ip(),
134+
remember: $request->input('remember', false)
135+
);
136+
137+
$message['token'] = $token;
138+
}
120139

121140
return view('oauth', [
122-
'message' => [
123-
'ok' => true,
124-
'provider' => $provider,
125-
'token' => $token,
126-
],
141+
'message' => $message,
127142
]);
128143
}
129144

130145
/**
131-
* Generate sanctum token on successful login
146+
* Login user
132147
* @throws ValidationException
133148
*/
134149
public function login(Request $request): JsonResponse
@@ -140,30 +155,23 @@ public function login(Request $request): JsonResponse
140155

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

143-
if (!$user || !Hash::check($request->password, $user->password)) {
158+
if (!$user) {
144159
throw ValidationException::withMessages([
145160
'email' => __('auth.failed'),
146161
]);
147162
}
148163

149-
$token = $user->createDeviceToken(
150-
device: $request->deviceName(),
151-
ip: $request->ip(),
152-
remember: $request->input('remember', false)
153-
);
164+
$result = $this->authService->login($request, $user);
154165

155-
return response()->json([
156-
'ok' => true,
157-
'token' => $token,
158-
]);
166+
return response()->json($result);
159167
}
160168

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

168176
return response()->json([
169177
'ok' => true,
@@ -226,7 +234,7 @@ public function resetPassword(Request $request): JsonResponse
226234
{
227235
$request->validate([
228236
'token' => ['required'],
229-
'email' => ['required', 'email', 'exists:'.User::class],
237+
'email' => ['required', 'email', 'exists:' . User::class],
230238
'password' => ['required', 'confirmed', Rules\Password::defaults()],
231239
]);
232240

@@ -287,7 +295,7 @@ public function verificationNotification(Request $request): JsonResponse
287295
'email' => ['required', 'email'],
288296
]);
289297

290-
$user = $request->user()?: User::where('email', $request->email)->whereNull('email_verified_at')->first();
298+
$user = $request->user() ?: User::where('email', $request->email)->whereNull('email_verified_at')->first();
291299

292300
abort_if(!$user, 400);
293301

@@ -304,24 +312,7 @@ public function verificationNotification(Request $request): JsonResponse
304312
*/
305313
public function devices(Request $request): JsonResponse
306314
{
307-
$user = $request->user();
308-
309-
$devices = $user->tokens()
310-
->select('id', 'name', 'ip', 'last_used_at')
311-
->orderBy('last_used_at', 'DESC')
312-
->get();
313-
314-
$currentToken = $user->currentAccessToken();
315-
316-
foreach ($devices as $device) {
317-
$device->hash = Crypt::encryptString($device->id);
318-
319-
if ($currentToken->id === $device->id) {
320-
$device->is_current = true;
321-
}
322-
323-
unset($device->id);
324-
}
315+
$devices = $this->authService->getDevices($request);
325316

326317
return response()->json([
327318
'ok' => true,
@@ -330,21 +321,15 @@ public function devices(Request $request): JsonResponse
330321
}
331322

332323
/**
333-
* Revoke token by id
324+
* Disconnect device by id
334325
*/
335326
public function deviceDisconnect(Request $request): JsonResponse
336327
{
337328
$request->validate([
338-
'hash' => 'required',
329+
'key' => 'required|string',
339330
]);
340331

341-
$user = $request->user();
342-
343-
$id = (int) Crypt::decryptString($request->hash);
344-
345-
if (!empty($id)) {
346-
$user->tokens()->where('id', $id)->delete();
347-
}
332+
$this->authService->disconnectDevice($request);
348333

349334
return response()->json([
350335
'ok' => true,

app/Models/PersonalAccessToken.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ class PersonalAccessToken extends SanctumPersonalAccessToken
1414
protected function lastUsedAt(): Attribute
1515
{
1616
return Attribute::make(
17-
set: fn (string $value) => $this->getOriginal('last_used_at') < now()->parse($value)->subMinute()
17+
set: fn(string $value) => $this->getOriginal('last_used_at') < now()->parse($value)->subMinute()
1818
? $value
1919
: $this->getOriginal('last_used_at'),
2020
);

0 commit comments

Comments
 (0)