├── .gitignore ├── README.md ├── composer.json ├── config └── azure.php └── src ├── Azure.php └── AzureServiceProvider.php /.gitignore: -------------------------------------------------------------------------------- 1 | /vendor/ 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Laravel Azure Middleware 2 | 3 | Provides Azure Authentication Middleware for a Laravel App. If you like this, checkout Laravel Saml Middleware 4 | 5 | ## Normal Installation 6 | 7 | 1. `composer require rootinc/laravel-azure-middleware` 8 | 2. run `php artisan vendor:publish --provider="RootInc\LaravelAzureMiddleware\AzureServiceProvider"` to install config file to `config/azure.php` 9 | 3. In our routes folder (most likely `web.php`), add 10 | ```php 11 | Route::get('/login/azure', '\RootInc\LaravelAzureMiddleware\Azure@azure') 12 | ->name('azure.login'); 13 | Route::get('/login/azurecallback', '\RootInc\LaravelAzureMiddleware\Azure@azurecallback') 14 | ->name('azure.callback'); 15 | ``` 16 | > NOTE: Only need the route names if configuring `redirect_uri` in the portal. 17 | 4. In our `App\Http\Kernel.php` add `'azure' => \RootInc\LaravelAzureMiddleware\Azure::class,` most likely to the `$routeMiddleware` array. 18 | 5. In our `.env` add `AZURE_TENANT_ID, AZURE_CLIENT_ID, AZURE_CLIENT_SECRET and AZURE_RESOURCE`. We can get these values/read more here: https://portal.azure.com/ (Hint: AZURE_RESOURCE should be https://graph.microsoft.com) 19 | 6. As of 0.8.0, we added `AZURE_SCOPE`, which are permissions to be used for the request. We can read more about these here: https://docs.microsoft.com/en-us/graph/api/resources/users?view=graph-rest-1.0 20 | 7. We also added an optional `AZURE_DOMAIN_HINT` that can be used to help users know which email address they should login with. More info here: https://azure.microsoft.com/en-us/updates/app-service-auth-and-azure-ad-domain-hints/ 21 | 8. Within our app on https://portal.azure.com/ point `reply url` to the `/login/azurecallback` route with the full url (ex: http://thewebsite.com/login/azurecallback). 22 | 9. Add the `azure` middleware to your route groups on any routes that needs protected by auth and enjoy :tada: 23 | 10. If you need custom callbacks, see [Extended Installation](#extended-installation). 24 | 25 | >NOTE: ~~You may need to add premissions for (legacy) Azure Active Directory Graph~~ As of 0.8.0, we are using v2 of Azure's login API, which allows us to pass scopes, or permissions we'd like to use. 26 | 27 | ## Routing 28 | 29 | `Route::get('/login/azure', '\RootInc\LaravelAzureMiddleware\Azure@azure')->name('azure.login');` First parameter can be wherever you want to route the azure login. Change as you would like. 30 | 31 | `Route::get('/login/azurecallback', '\RootInc\LaravelAzureMiddleware\Azure@azurecallback')->name('azure.callback');` First parameter can be whatever you want to route after your callback. Change as you would like. 32 | 33 | `Route::get('/logout/azure', '\RootInc\LaravelAzureMiddleware\Azure@azurelogout')->name('azure.logout);` First parameter can be whatever you want to route after your callback. Change as you would like. 34 | 35 | > NOTE: Only need the route names if configuring `redirect_uri` in the portal. 36 | 37 | ### Front End 38 | 39 | It's best to have an Office 365 button on your login webpage that routes to `route('azure.login')`. This can be as simple as an anchor tag like this `` 40 | 41 | ## Extended Installation 42 | 43 | The out-of-the-box implementation let's you login users. However, let's say we would like to store this user into a database, as well as login the user in with Laravel Auth. There are two callbacks that are recommended to extend from the Azure class called `success` and `fail`. The following provides information on how to extend the Root Laravel Azure Middleware Library: 44 | 45 | 1. To get started (assuming we've followed the [Normal Installation](#normal-installation) directions), create a file called `AppAzure.php` in the `App\Http\Middleware` folder. You can either do this through `artisan` or manually. 46 | 2. Add this as a starting point in this file: 47 | 48 | ```php 49 | setAccessToken($access_token); 69 | 70 | $graph_user = $graph->createRequest("GET", "/me") 71 | ->setReturnType(Model\User::class) 72 | ->execute(); 73 | 74 | $email = strtolower($graph_user->getUserPrincipalName()); 75 | 76 | $user = User::updateOrCreate(['email' => $email], [ 77 | 'name' => $graph_user->getGivenName() . ' ' . $graph_user->getSurname(), 78 | ]); 79 | 80 | Auth::login($user, true); 81 | 82 | return parent::success($request, $access_token, $refresh_token, $profile); 83 | } 84 | } 85 | ``` 86 | 87 | The above gives us a way to add/update users after a successful handshake.  `$profile` contains all sorts of metadata that we use to create or update our user. More information here: https://docs.microsoft.com/en-us/azure/active-directory/develop/active-directory-protocols-oauth-code#jwt-token-claims . The default implementation redirects to the intended url, or `/`, so we call the parent here. Feel free to not extend the default and to redirect elsewhere. 88 | 89 | 3. Our routes need to be updated to the following: 90 | 91 | ```php 92 | Route::get('/login/azure', '\App\Http\Middleware\AppAzure@azure') 93 | ->name('azure.login'); 94 | Route::get('/login/azurecallback', '\App\Http\Middleware\AppAzure@azurecallback') 95 | ->name('azure.callback'); 96 | Route::get('/logout/azure', '\App\Http\Middleware\AppAzure@azurelogout') 97 | ->name('azure.logout'); 98 | ``` 99 | 100 | 4. Finally, update `Kernel.php`'s `azure` key to be `'azure' => \App\Http\Middleware\AppAzure::class,` 101 | 102 | ## Other Extending Options 103 | 104 | #### Callback on Every Handshake 105 | 106 | As of v0.4.0, we added a callback after every successful request (handshake) from Azure. The default is to simply call the `$next` closure. However, let's say we want to update the user. Here's an example of how to go about that: 107 | 108 | ```php 109 | updated_at = Carbon::now(); 131 | 132 | $user->save(); 133 | 134 | return parent::handlecallback($request, $next, $access_token, $refresh_token); 135 | } 136 | } 137 | ``` 138 | 139 | Building off of our previous example from [Extended Installation](#extended-installation), we have a user in the Auth now (since we did `Auth::login` in the success callback). With the user model, we can update the user's `updated_at` field. The callback should call the closure, `$next($request);` and return it. In our case, the default implementation does this, so we call the parent here. 140 | 141 | #### Custom Redirect 142 | 143 | As of v0.6.0, we added the ability to customize the redirect method. For example, if the session token's expire, but the user is still authenticated with Laravel, we can check for that with this example: 144 | 145 | ```php 146 | azure($request); 163 | } 164 | else 165 | { 166 | return parent::redirect($request); 167 | } 168 | } 169 | } 170 | ``` 171 | 172 | #### Different Login Route 173 | 174 | As of v0.4.0, we added the ability to change the `$login_route` in the middleware. Building off [Extended Installation](#extended-installation), in our `AppAzure` class, we can simply set `$login_route` to whatever. For example: 175 | 176 | ```php 177 | baseUrl . config('azure.tenant_id') . $this->route2 . "authorize?response_type=code&client_id=" . config('azure.client.id') . "&domain_hint=" . urlencode(config('azure.domain_hint')) . "&scope=" . urldecode(config('azure.scope')); 212 | 213 | return Route::has('azure.callback') ? $url . '&redirect_uri=' . urlencode(route('azure.callback')) : $url; 214 | } 215 | 216 | public function azure(Request $request) 217 | { 218 | $user = Auth::user(); 219 | 220 | $away = $this->getAzureUrl(); 221 | 222 | if ($user) 223 | { 224 | $away .= "&login_hint=" . $user->email; 225 | } 226 | 227 | return redirect()->away($away); 228 | } 229 | } 230 | ``` 231 | 232 | #### Using in a Multi-Tenanted Application 233 | 234 | If the desired use case requires a multi-tenanted application you can simply provide `common` in the .env file instead of a Tenant ID. eg. `AZURE_TENANT_ID=common`. 235 | 236 | This works by sending your end users to the generic login routes provided by Microsoft and for all intents and purposes shouldn't appear any different for development either. It should be known that there some inherent drawbacks to this approach as mentioned by in the MS Dev docs here: 237 | > When a single tenant application validates a token, it checks the signature of the token against the signing keys from the metadata document. This test allows it to make sure the issuer value in the token matches the one that was found in the metadata document. 238 | >Because the /common endpoint doesn’t correspond to a tenant and isn’t an issuer, when you examine the issuer value in the metadata for /common it has a templated URL instead of an actual value... 239 | 240 | Additional information regarding this can be found [here](https://docs.microsoft.com/en-us/azure/active-directory/develop/howto-convert-app-to-be-multi-tenant#update-your-code-to-handle-multiple-issuer-values). 241 | 242 | ## Testing with Laravel Azure Middleware 243 | 244 | As of v0.7.0, we added integration with Laravel's tests by calling `actingAs` for HTTP tests or `loginAs` with Dusk. This assumes that we are using the `Auth::login` method in the success callback, shown at [Extended Installation](#extended-installation). There is no need to do anything in our `AppAzure` class, unless we needed to overwrite the default behavior, which is shown below: 245 | 246 | ```php 247 | redirect($request, $next); 268 | } 269 | 270 | return $this->handlecallback($request, $next, null, null); 271 | } 272 | } 273 | ``` 274 | 275 | The above will call the class's redirect method, if it can't find a user in Laravel's auth. Otherwise, the above will call the class's handlecallback method. Therefore, tests can check if the correct redirection is happening, or that handlecallback is working correctly (which by default calls `$next($request);`). 276 | 277 | ## Contributing 278 | 279 | Thank you for considering contributing to the Laravel Azure Middleware! To encourage active collaboration, we encourage pull requests, not just issues. 280 | 281 | If you file an issue, the issue should contain a title and a clear description of the issue. You should also include as much relevant information as possible and a code sample that demonstrates the issue. The goal of a issue is to make it easy for yourself - and others - to replicate the bug and develop a fix. 282 | 283 | ## License 284 | 285 | The Laravel Azure Middleware is open-sourced software licensed under the [MIT license](http://opensource.org/licenses/MIT). 286 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "rootinc/laravel-azure-middleware", 3 | "description": "Azure Middleware Auth", 4 | "type": "library", 5 | "require": { 6 | "php": ">=5.6.4", 7 | "laravel/framework": ">=5.4.0", 8 | "guzzlehttp/guzzle": ">=6.2", 9 | "microsoft/microsoft-graph": "^1.5" 10 | }, 11 | "license": "MIT", 12 | "authors": [ 13 | { 14 | "name": "Root Inc", 15 | "email": "djewett@rootinc.com" 16 | } 17 | ], 18 | "autoload": { 19 | "psr-4": { 20 | "RootInc\\LaravelAzureMiddleware\\": "src/" 21 | } 22 | }, 23 | "extra": { 24 | "laravel": { 25 | "providers": [ 26 | "RootInc\\LaravelAzureMiddleware\\AzureServiceProvider" 27 | ] 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /config/azure.php: -------------------------------------------------------------------------------- 1 | env('AZURE_TENANT_ID', ''), 14 | 15 | /* 16 | |-------------------------------------------------------------------------- 17 | | Client Info 18 | |-------------------------------------------------------------------------- 19 | | 20 | | These values are equal to 'Application (client) ID' and the secret you 21 | | made in 'Client secrets' as found in the Azure portal 22 | | 23 | */ 24 | 'client' => [ 25 | 'id' => env('AZURE_CLIENT_ID', ''), 26 | 'secret' => env('AZURE_CLIENT_SECRET', ''), 27 | ], 28 | 29 | /* 30 | |-------------------------------------------------------------------------- 31 | | Resource ID 32 | |-------------------------------------------------------------------------- 33 | | 34 | | This value is equal to the 'Object ID' as found in the Azure portal 35 | | 36 | */ 37 | 'resource' => env('AZURE_RESOURCE', ''), 38 | 39 | /* 40 | |-------------------------------------------------------------------------- 41 | | Domain Hint 42 | |-------------------------------------------------------------------------- 43 | | 44 | | This value can be used to help users know which email address they 45 | | should login with. 46 | | https://azure.microsoft.com/en-us/updates/app-service-auth-and-azure-ad-domain-hints/ 47 | | 48 | */ 49 | 'domain_hint' => env('AZURE_DOMAIN_HINT', ''), 50 | 51 | /* 52 | |-------------------------------------------------------------------------- 53 | | Permission Scope 54 | |-------------------------------------------------------------------------- 55 | | 56 | | This value indicates the permissions granted to the OAUTH session. 57 | | https://docs.microsoft.com/en-us/graph/api/resources/users?view=graph-rest-1.0 58 | | 59 | */ 60 | 'scope' => env('AZURE_SCOPE', 'User.Read'), 61 | ]; -------------------------------------------------------------------------------- /src/Azure.php: -------------------------------------------------------------------------------- 1 | session()->get('_rootinc_azure_access_token'); 35 | $refresh_token = $request->session()->get('_rootinc_azure_refresh_token'); 36 | 37 | if (config('app.env') === "testing") 38 | { 39 | return $this->handleTesting($request, $next, $access_token, $refresh_token); 40 | } 41 | 42 | if (!$access_token || !$refresh_token) 43 | { 44 | return $this->redirect($request); 45 | } 46 | 47 | $client = new Client(); 48 | 49 | try { 50 | $form_params = [ 51 | 'grant_type' => 'refresh_token', 52 | 'client_id' => config('azure.client.id'), 53 | 'client_secret' => config('azure.client.secret'), 54 | 'refresh_token' => $refresh_token, 55 | 'resource' => config('azure.resource'), 56 | ]; 57 | 58 | if (Route::has('azure.callback')) { 59 | $form_params['redirect_uri'] = route('azure.callback'); 60 | } 61 | 62 | $response = $client->request('POST', $this->baseUrl . config('azure.tenant_id') . $this->route . "token", [ 63 | 'form_params' => $form_params, 64 | ]); 65 | 66 | $contents = json_decode($response->getBody()->getContents()); 67 | } catch(RequestException $e) { 68 | $this->fail($request, $e); 69 | } 70 | 71 | if (empty($contents->access_token) || empty($contents->refresh_token)) { 72 | $this->fail($request, new \Exception('Missing tokens in response contents')); 73 | } 74 | 75 | $request->session()->put('_rootinc_azure_access_token', $contents->access_token); 76 | $request->session()->put('_rootinc_azure_refresh_token', $contents->refresh_token); 77 | 78 | return $this->handlecallback($request, $next, $access_token, $refresh_token); 79 | } 80 | 81 | /** 82 | * Handle an incoming request in a testing environment 83 | * Assumes tester is calling actingAs or loginAs during testing to run this correctly 84 | * 85 | * @param \Illuminate\Http\Request $request 86 | * @param Closure $next 87 | * @return \Illuminate\Http\RedirectResponse|\Illuminate\Routing\Redirector|mixed 88 | */ 89 | protected function handleTesting(Request $request, Closure $next) 90 | { 91 | $user = Auth::user(); 92 | 93 | if (!isset($user)) 94 | { 95 | return $this->redirect($request, $next); 96 | } 97 | 98 | return $this->handlecallback($request, $next, null, null); 99 | } 100 | 101 | /** 102 | * Gets the azure url 103 | * 104 | * @return String 105 | */ 106 | public function getAzureUrl() 107 | { 108 | $url = $this->baseUrl . config('azure.tenant_id') . $this->route2 . "authorize?response_type=code&client_id=" . config('azure.client.id') . "&domain_hint=" . urlencode(config('azure.domain_hint')) . "&scope=" . urldecode(config('azure.scope')); 109 | 110 | return Route::has('azure.callback') ? $url . '&redirect_uri=' . urlencode(route('azure.callback')) : $url; 111 | } 112 | 113 | /** 114 | * Redirects to the Azure route. Typically used to point a web route to this method. 115 | * For example: Route::get('/login/azure', '\RootInc\LaravelAzureMiddleware\Azure@azure'); 116 | * 117 | * @param \Illuminate\Http\Request $request 118 | * @return \Illuminate\Http\RedirectResponse|\Illuminate\Routing\Redirector|mixed 119 | */ 120 | public function azure(Request $request) 121 | { 122 | return redirect()->away( $this->getAzureUrl() ); 123 | } 124 | 125 | /** 126 | * Customized Redirect method 127 | * 128 | * @param \Illuminate\Http\Request $request 129 | * @return \Illuminate\Http\RedirectResponse|\Illuminate\Routing\Redirector|mixed 130 | */ 131 | protected function redirect(Request $request) 132 | { 133 | return redirect()->guest($this->login_route); 134 | } 135 | 136 | /** 137 | * Callback after login from Azure 138 | * 139 | * @param \Illuminate\Http\Request $request 140 | * @return \Illuminate\Http\RedirectResponse|\Illuminate\Routing\Redirector|mixed 141 | * @throws \Exception 142 | */ 143 | public function azurecallback(Request $request) 144 | { 145 | $client = new Client(); 146 | 147 | $code = $request->input('code'); 148 | 149 | try { 150 | $response = $client->request('POST', $this->baseUrl . config('azure.tenant_id') . $this->route . "token", [ 151 | 'form_params' => [ 152 | 'grant_type' => 'authorization_code', 153 | 'client_id' => config('azure.client.id'), 154 | 'client_secret' => config('azure.client.secret'), 155 | 'code' => $code, 156 | 'resource' => config('azure.resource'), 157 | ] 158 | ]); 159 | 160 | $contents = json_decode($response->getBody()->getContents()); 161 | } catch(RequestException $e) { 162 | return $this->fail($request, $e); 163 | } 164 | 165 | $access_token = $contents->access_token; 166 | $refresh_token = $contents->refresh_token; 167 | $profile = json_decode( base64_decode( explode(".", $contents->id_token)[1]) ); 168 | 169 | $request->session()->put('_rootinc_azure_access_token', $access_token); 170 | $request->session()->put('_rootinc_azure_refresh_token', $refresh_token); 171 | 172 | return $this->success($request, $access_token, $refresh_token, $profile); 173 | } 174 | 175 | /** 176 | * Handler that is called when a successful login has taken place for the first time 177 | * 178 | * @param \Illuminate\Http\Request $request 179 | * @param String $access_token 180 | * @param String $refresh_token 181 | * @param mixed $profile 182 | * @return \Illuminate\Http\RedirectResponse|\Illuminate\Routing\Redirector|mixed 183 | */ 184 | protected function success(Request $request, $access_token, $refresh_token, $profile) 185 | { 186 | return redirect()->intended("/"); 187 | } 188 | 189 | /** 190 | * Handler that is called when a failed handshake has taken place 191 | * 192 | * @param \Illuminate\Http\Request $request 193 | * @param \Exception $e 194 | * @return string 195 | */ 196 | protected function fail(Request $request, \Exception $e) 197 | { 198 | // JustinByrne updated the original code from smitthhyy (18 Dec 2019) to change to an array to allow for multiple error codes. 199 | if ($request->isMethod('get')) { 200 | $errorDescription = trim(substr($request->query('error_description', 'SOMETHING_ELSE'), 0, 11)); 201 | 202 | $azureErrors = [ 203 | 'AADSTS50105' => [ 204 | 'HTTP_CODE' => '403', 205 | 'msg' => 'User is not authorized within Azure AD to access this application.', 206 | ], 207 | 'AADSTS90072' => [ 208 | 'HTTP_CODE' => '403', 209 | 'msg' => 'The logged on User is not in the allowed Tenant. Log in with a User in the allowed Tenant.', 210 | ], 211 | ]; 212 | 213 | if (array_key_exists($errorDescription, $azureErrors)) { 214 | return abort($azureErrors[$errorDescription]['HTTP_CODE'], $azureErrors[$errorDescription]['msg']); 215 | } 216 | } 217 | 218 | return implode("", explode(PHP_EOL, $e->getMessage())); 219 | } 220 | 221 | /** 222 | * Handler that is called every request when a user is logged in 223 | * 224 | * @param \Illuminate\Http\Request $request 225 | * @param Closure $next 226 | * @param String $access_token 227 | * @param String $refresh_token 228 | * @return \Illuminate\Http\RedirectResponse|\Illuminate\Routing\Redirector|mixed 229 | */ 230 | protected function handlecallback(Request $request, Closure $next, $access_token, $refresh_token) 231 | { 232 | return $next($request); 233 | } 234 | 235 | /** 236 | * Gets the logout url 237 | * 238 | * @return String 239 | */ 240 | public function getLogoutUrl() 241 | { 242 | return $this->baseUrl . "common" . $this->route . "logout"; 243 | } 244 | 245 | /** 246 | * Redirects to the Azure logout route. Typically used to point a web route to this method. 247 | * For example: Route::get('/logout/azure', '\RootInc\LaravelAzureMiddleware\Azure@azurelogout'); 248 | * 249 | * @param \Illuminate\Http\Request $request 250 | * @return \Illuminate\Http\RedirectResponse|\Illuminate\Routing\Redirector|mixed 251 | */ 252 | public function azurelogout(Request $request) 253 | { 254 | $request->session()->pull('_rootinc_azure_access_token'); 255 | $request->session()->pull('_rootinc_azure_refresh_token'); 256 | 257 | return redirect()->away($this->getLogoutUrl()); 258 | } 259 | } 260 | -------------------------------------------------------------------------------- /src/AzureServiceProvider.php: -------------------------------------------------------------------------------- 1 | app->runningInConsole()) 17 | { 18 | $this->publishes([ 19 | __DIR__ . '/../config/azure.php' => config_path('azure.php'), 20 | ], 'config'); 21 | } 22 | } 23 | 24 | /** 25 | * {@inheritdoc} 26 | */ 27 | public function register() 28 | { 29 | $this->mergeConfigFrom( 30 | __DIR__ . '/../config/azure.php', 'azure' 31 | ); 32 | } 33 | } --------------------------------------------------------------------------------