Welcome to the next step in securing your Laravel applications! In this unit, we will explore an essential aspect of web development — securing endpoints using middleware. Previously, we learned how to authenticate users with Bcrypt
to protect our application. Here, we will build on that knowledge to ensure that only authenticated users can access specific parts of our application. This lesson will help you incorporate a vital layer of security into your web applications.
In this section, we will dive into the exciting world of middleware and how it can be used to secure endpoints in Laravel applications. In the previous lesson we covered how to create registration and login functionalities for users. But authentication is only the first step in securing your application. In this lesson, you will learn how to use middleware to restrict access to specific routes based on user authentication. Specifically, you will learn how to secure the todos
route so that only authenticated users can access it. Let's get started!
Before we dive into the code let's understand the concept of sessions.
A session is a way to store information about a user across multiple pages. When a user logs in, a session is created that stores the user's information. This information is then used to authenticate the user on subsequent requests. Sessions are stored on the server and are accessible across multiple pages.
Let's discuss the analaog of what sessions are in real life. Imagine you are at a party and you are given a wristband to wear. This wristband allows you to access different parts of the party. The wristband is like a session. It stores information about you and allows you to access different parts of the party. In the same way, a session stores information about a user and allows them to access different parts of a website.
As mentioned, the sessions are stored in the backend and they usually have a TTL (Time To Live) which is the time the session will be stored on the server. After the TTL expires, the session is destroyed.
Let's see the steps how this occurs:
- When the user logs ins, a new session is created for the user ID and the user is authenticated. This is usually achieved by creating a unique token for the user and storing it in the session.
- This token is then returned to the user and is used to authenticate the user on subsequent requests - this is usually done by storing the token in a cookie or in the local storage of the browser on the client side.
- When the user makes a request to the server, the server checks the token in the session to authenticate the user. If the token is valid, the user is authenticated and the request is processed. If the token is invalid, the user is redirected to the login page.
- When the user logs out, the session is destroyed and the user is logged out.
Let's now see how we can implement this logic with a real example.
Let's start by the controller, where we will modify the login method to store the user's ID in the session. This will allow us to authenticate the user on subsequent requests.
php1// app/app/Http/Controllers/UserController.php 2<?php 3 4namespace App\Http\Controllers; 5 6use App\Models\User; 7use App\Models\Todo; 8use Illuminate\Http\Request; 9use Illuminate\Support\Facades\Hash; 10use Illuminate\Support\Facades\Auth; 11use Illuminate\Support\Str; 12 13 14class UserController extends Controller 15{ 16 protected $sessionFilePath = 'app/session_data.json'; 17 18 public function login(Request $request) 19 { 20 $request->validate([ 21 'username' => 'required', 22 'password' => 'required', 23 ]); 24 25 $user = User::where('username', $request->username)->first(); 26 27 if ($user && Hash::check($request->password, $user->password)) { 28 // Store user ID in session data 29 $sessionData = $this->getSessionData(); 30 31 $newSessionData = [ 32 'userId' => $user->id, 33 'token' => Str::uuid(), 34 ]; 35 36 $sessionData[$user->id] = $newSessionData; 37 $this->saveSessionData($sessionData); 38 39 return response()->json(['message' => 'Login successful', 'token' => $newSessionData['token'], 'user' => $user->id], 200); 40 } 41 42 return response()->json(['message' => 'Invalid credentials'], 401); 43 } 44 45 protected function getSessionData() 46 { 47 if (file_exists($this->sessionFilePath)) { 48 return json_decode(file_get_contents($this->sessionFilePath), true); 49 } 50 return []; 51 } 52 53 protected function saveSessionData($data) 54 { 55 file_put_contents(storage_path($this->sessionFilePath), json_encode($data)); 56 } 57}
Let's examine the code above thoroughly:
- We have added a new method
getSessionData
that reads the session data from a file atstorage/app/session_data.json
. This file will store the session data for all users. Each entry is a key-value pair where the key is the user ID and the value is the session data for that user (which includes the user ID and the token). - We have added a new method
saveSessionData
that writes the session data to the file atstorage/app/session_data.json
. - In the
login
method, we have added the logic to store the user ID in the session data. We generate a new token usingStr::uuid()
and store the user ID and the token in the session data. We then save the session data to the file and return the token to the user.
Let's now examine how the client-side code will look like for login:
HTML, XML1<!DOCTYPE html> 2<html lang="en"> 3<head> 4 <meta charset="UTF-8"> 5 <meta name="viewport" content="width=device-width, initial-scale=1.0"> 6 <title>Login</title> 7 <script> 8 async function handleLogin(event) { 9 event.preventDefault(); 10 11 const username = document.getElementById('username').value; 12 const password = document.getElementById('password').value; 13 14 try { 15 const response = await fetch('/login', { 16 method: 'POST', 17 headers: { 18 'Content-Type': 'application/json', 19 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').getAttribute('content') 20 }, 21 body: JSON.stringify({ username, password }) 22 }); 23 24 console.log(response); 25 if (!response.ok) { 26 throw new Error('Invalid credentials'); 27 } 28 29 const data = await response.json(); 30 // Assuming the token is included in the response data 31 const token = data.token; 32 const user = data.user; 33 34 if (token) { 35 // Store the token (e.g., in the local storage or a cookie) 36 localStorage.setItem('auth_token', token); 37 localStorage.setItem('user', user); 38 alert('Login successful. You can go to /todos now!'); 39 // Optionally redirect user to another page or perform another action 40 } 41 } catch (error) { 42 console.error('Error during login:', error); 43 alert(error.message); 44 } 45 } 46 </script> 47</head> 48<body class="bg-gray-100"> 49 <div class="container mx-auto mt-10"> 50 <div class="max-w-md mx-auto bg-white p-5 rounded shadow"> 51 <h2 class="text-2xl mb-4">Login</h2> 52 <form onsubmit="handleLogin(event)"> 53 @csrf 54 <div class="mb-4"> 55 <label for="username" class="block text-sm font-medium text-gray-700">Username</label> 56 <input type="text" name="username" id="username" class="mt-1 block w-full border-gray-300 rounded-md shadow-sm" required> 57 </div> 58 <div class="mb-4"> 59 <label for="password" class="block text-sm font-medium text-gray-700">Password</label> 60 <input type="password" name="password" id="password" class="mt-1 block w-full border-gray-300 rounded-md shadow-sm" required> 61 </div> 62 <div class="mb-4"> 63 <button type="submit" class="bg-blue-500 text-white px-4 py-2 rounded">Login</button> 64 </div> 65 </form> 66 </div> 67 </div> 68</body> 69<meta name="csrf-token" content="{{ csrf_token() }}"> 70</html>
Notice that when user hits the Login
button, the handleLogin
function is called. This function sends a POST
request to the /login
endpoint with the username and password. If the login is successful, the token is stored in the local storage and an alert is shown to the user.
Let's now see how we secure the todos
route using middleware. First let's note, that the controller does nothing for securing the todos
route, it simply returns all the todos as before:
php1 public function todos(Request $request) 2 { 3 $defaultTodos = [ 4 ['title' => 'Welcome Todo 1', 'description' => 'This is your first todo!'], 5 ['title' => 'Welcome Todo 2', 'description' => 'Explore and add more todos!'], 6 ]; 7 8 foreach ($defaultTodos as $todo) { 9 Todo::create($todo); 10 } 11 $todos = Todo::all(); 12 return response()->json(['todos' => $todos], 200); 13 }
All the security logic will be implemented in the middleware. Let's create a new middleware called CheckToken
:
php1<?php 2 3namespace App\Http\Middleware; 4 5use Closure; 6use Illuminate\Http\Request; 7use Illuminate\Support\Facades\Log; 8 9class CheckToken 10{ 11 public function handle(Request $request, Closure $next) 12 { 13 $token = $request->input('token'); 14 $user = $request->input('user'); 15 16 if (!$user || !$token) { 17 return response()->json(['error' => 'Missing token or user information'], 400); 18 } 19 20 $sessionData = $this->getSessionData(); 21 22 if (isset($sessionData[$user]) && $sessionData[$user]['token'] === $token) { 23 return $next($request); 24 } 25 26 Log::warning('Unauthorized access attempt', ['user' => $user, 'token' => $token]); 27 28 return response()->json(['error' => 'Unauthorized'], 401); 29 } 30 31 protected function getSessionData() 32 { 33 if (file_exists(storage_path('app/session_data.json'))) { 34 return json_decode(file_get_contents(storage_path('app/session_data.json')), true); 35 } 36 return []; 37 } 38}
Let's examine the code above step by step:
- We have created a new middleware called
CheckToken
that checks if the user is authenticated based on the token and user ID passed in the request. - In the
handle
method, we first check if the token and user ID are present in the request. If they are not present, we return a400
error. - We then read the session data from the file and check if the user ID and token match the session data. If they match, we allow the request to proceed. If they do not match, we log a warning and return a
401
error. - We have added a new method
getSessionData
that reads the session data from the file atstorage/app/session_data.json
.
Remember, the middleware is registered in the app/Http/Kernel.php
file in the $routeMiddleware
array:
php1 protected $routeMiddleware = [ 2 ... 3 'check.token' => \App\Http\Middleware\CheckToken::class, 4 ];
Now, let's apply the middleware to the todos
route in the routes/web.php
file:
php1Route::get('/todos', 'App\Http\Controllers\UserController@todos')->middleware('check.token');
With this, when users try to access the /todos
route, the CheckToken
middleware will be executed first. If the user is not authenticated, the middleware will return a 401
error. If the user is authenticated, the middleware will allow the request to proceed, and the todos will be returned as before.
Let's also understand how the token and user ID are passed in the request.
HTML, XML1<!DOCTYPE html> 2<html lang="en"> 3<head> 4 <meta charset="UTF-8"> 5 <meta name="viewport" content="width=device-width, initial-scale=1.0"> 6 <title>Todos</title> 7 <script> 8 async function fetchTodos() { 9 try { 10 const token = localStorage.getItem('auth_token'); 11 const user = localStorage.getItem('user'); 12 13 if (!token || !user) { 14 alert('Not authenticated. Please log in.'); 15 } 16 console.log(token) 17 const response = await fetch('/get-todos?token=' + token + '&user=' + user); 18 19 if (!response.ok) { 20 throw new Error(`Error: ${response.status} ${response.statusText}`); 21 } 22 const data = await response.json(); 23 const todosList = document.getElementById('todos-list'); 24 todosList.innerHTML = ''; // Clear previous todos 25 26 if (data.todos && data.todos.length) { 27 data.todos.forEach(todo => { 28 const listItem = document.createElement('li'); 29 listItem.textContent = `${todo.title}: ${todo.description}`; 30 todosList.appendChild(listItem); 31 }); 32 } else { 33 todosList.innerHTML = '<li>No todos available.</li>'; 34 } 35 } catch (error) { 36 console.error('Error fetching todos:', error); 37 document.getElementById('todos-list').innerHTML = `<li>Error loading todos.</li>`; 38 } 39 } 40 41 window.onload = fetchTodos; 42 </script> 43</head> 44<body class="bg-gray-100"> 45 <div class="container mx-auto mt-10"> 46 <div class="max-w-md mx-auto bg-white p-5 rounded shadow"> 47 <h2 class="text-2xl mb-4">Todos</h2> 48 <ul id="todos-list" class="list-disc pl-5"> 49 <!-- Todos will be populated here --> 50 </ul> 51 </div> 52 </div> 53</body> 54</html>
Let's examine the code above:
- We have added a new function
fetchTodos
that fetches the todos from the/get-todos
endpoint. The token and user ID are passed as query parameters in the request. - We have added a new
window.onload
event listener that calls thefetchTodos
function when the page loads. - When the
fetchTodos
function is called, it first checks if the token and user ID are present in the local storage. If they are not present, it shows an alert to the user. - It then sends a
GET
request to the/get-todos
endpoint with the token and user ID as query parameters. If the request is successful, it populates the todos in thetodos-list
element. If the request fails, it shows an error message. - The todos are displayed as a list of items with the title and description of each todo.
With this setup, only authenticated users can access the /todos
route. If a user is not authenticated, they will be redirected to the login page. This adds an extra layer of security to the application and ensures that sensitive data is protected.
Finally, let's see how we can implement a logout functionality. When a user logs out
php1class UserController extends Controller 2{ 3 // ... 4 public function logout(Request $request) 5 { 6 $user = $request->input('user'); 7 8 $sessionData = $this->getSessionData(); 9 10 if (isset($sessionData[$user])) { 11 unset($sessionData[$user]); 12 $this->saveSessionData($sessionData); 13 } 14 15 return response()->json(['message' => 'Logged out successfully'], 200); 16 } 17}
Notice, that in this method we simply remove the user from the session data. This effectively logs the user out. The client-side code for logging out will look like this:
HTML, XML1<script> 2 ... 3 async function logout() { 4 localStorage.removeItem('auth_token'); 5 alert('Logged out successfully'); 6 fetch('/logout&user=' + localStorage.getItem('user'), { 7 method: 'POST', 8 headers: { 9 'Content-Type': 'application/json' 10 }, 11 }); 12 } 13</script> 14 15... 16 17<button onclick="logout()">Logout</button>
When the user clicks the Logout
button, the logout
function is called. This function removes the token from the local storage and sends a POST
request to the /logout
endpoint with the user ID. The user is then logged out and the session data is updated.
With this setup, you have implemented a complete authentication system with login, logout, and session management in your Laravel application. This adds an extra layer of security to your application and ensures that only authenticated users can access sensitive data.
Securing endpoints is vital in web application development. Middleware acts as a security gate, ensuring that unauthorized users cannot access sensitive parts of your application. By implementing token-based authentication through middleware, you ensure that user data is protected, thereby enhancing your application's reliability and trustworthiness. Middleware not only helps maintain the security of your application but also enforces clean, organized code practices. Ready to strengthen your application's defenses? Let's start practicing!