PHP微服务的认证模式介绍

447 阅读12分钟

微服务是一种越来越流行的架构,因为它们允许你将应用开发分割成更小、更容易管理的部分。然而,在实施认证时,微服务带来了复杂性。一般来说,在传统的单体应用中,你只需要担心认证的一个入口点。不过,微服务有多种设置方式,也有同样多的认证模式可供选择。

在本教程中,你将看到如何建立一个由四个微服务和一个简单的API网关组成的小型演示应用程序。我将分享一些方便的模式,以确保对微服务的访问安全。这里有一个预期架构的粗略图和一个显示组件间通信流的序列图。

粗略的架构:

Rough architecture diagram for the demo app with 4 microservices and a gateway API

序列图:

Diagram showing the sequence flow in the demo app with 4 microservices, an API gateway, and Okta auth

实施

在你开始之前,你需要安装一些依赖性的东西,以便跟上进度。首先,你需要DockerDocker Compose,因为本教程主要依靠容器来运行微服务。如果你的机器上还没有安装Docker,请按照Docker为你选择的操作系统提供的安装说明

本教程还在很大程度上利用Okta来实现各种认证模式。因此,你将需要创建一个Okta开发者账户。现在,请创建一个免费账户。本教程将涵盖你需要执行的任何操作,因为它们会在以后出现。

最后,为了测试API和认证方法,确保你安装了最新版本的Postman。Postman极大地简化了获取OAuth令牌和调用API的过程。

由于本教程主要集中在认证模式本身,实际的微服务并没有太大意义,只要你有一个API可以调用。你可以创建一些Laravel项目来启动和运行一些微服务。虽然Laravel对于这种简单的用例来说有点偏重,但它设置起来很快,也很容易,带有大部分你需要的开箱即用的库,并且有一个完善的中间件系统,你可以用它来实现这些认证模式。要创建Laravel项目, 官方文档建议使用laravel.build 网站, 它可以作为一个Bash脚本供你运行.在把脚本输入Bash之前, 最好先自己阅读一下, 所以如果你不确定的话, 一定要在执行脚本之前查看一下.它通过获取path 参数并将其作为项目名称来工作。

开始使用微服务

首先,创建一个目录来存放本教程中的所有微服务,命名为php-microservices-democd ,然后执行以下命令。curl -s "https://laravel.build/api-gateway?with=redis" | bash.这将创建一个包含Laravel项目的目录,名为api-gateway 。一旦这一步完成, 你将需要再运行四次,就像这样; 每一个你将创建的微服务一次。

# service a
curl -s "https://laravel.build/microservice-a?with=redis" | bash

# service b
curl -s "https://laravel.build/microservice-b?with=redis" | bash

# service c
curl -s "https://laravel.build/microservice-c?with=redis" | bash

# service d
curl -s "https://laravel.build/microservice-d?with=redis" | bash

现在我们的目录应该总共包含五个项目.这些项目是要用Laravel Sail来运行的, 但它并不是同时运行五个项目的最佳选择.使用Sail来安装依赖关系和执行artisan 命令, 但为了运行API, 在教程目录中创建一个新的docker-compose.yml 文件.这个文件将允许你同时启动所有的容器。幸运的是,由于API将是简单的,你甚至不需要数据库,只需要PHP容器。在你新创建的docker-compose.yml ,添加以下内容。

version: '2.1'
networks:
 php_microservices_demo:
   external: true
services:
 api_gateway:
   build:
     context: ./api-gateway/vendor/laravel/sail/runtimes/8.0
     dockerfile: Dockerfile
     args:
       WWWGROUP: '${GID}'
   image: sail-8.0/app
   extra_hosts:
     - 'host.docker.internal:host-gateway'
   ports:
     - '8080:80'
   environment:
     WWWUSER: '${UID}'
   volumes:
     - './api-gateway/:/var/www/html'
   networks:
     - php_microservices_demo
 microservice-a:
   build:
     context: ./microservice-a/vendor/laravel/sail/runtimes/8.0
     dockerfile: Dockerfile
     args:
       WWWGROUP: '${GID}'
   image: sail-8.0/app
   extra_hosts:
     - 'host.docker.internal:host-gateway'
   environment:
     WWWUSER: '${UID}'
   volumes:
     - './microservice-a/:/var/www/html'
   networks:
     - php_microservices_demo
 microservice-b:
   build:
     context: ./microservice-b/vendor/laravel/sail/runtimes/8.0
     dockerfile: Dockerfile
     args:
       WWWGROUP: '${GID}'
   image: sail-8.0/app
   extra_hosts:
     - 'host.docker.internal:host-gateway'
   environment:
     WWWUSER: '${UID}'
   volumes:
     - './microservice-b/:/var/www/html'
   networks:
     - php_microservices_demo
 microservice-c:
   build:
     context: ./microservice-c/vendor/laravel/sail/runtimes/8.0
     dockerfile: Dockerfile
     args:
       WWWGROUP: '${GID}'
   image: sail-8.0/app
   extra_hosts:
     - 'host.docker.internal:host-gateway'
   environment:
     WWWUSER: '${UID}'
   volumes:
     - './microservice-c/:/var/www/html'
   networks:
     - php_microservices_demo
 microservice-d:
   build:
     context: ./microservice-d/vendor/laravel/sail/runtimes/8.0
     dockerfile: Dockerfile
     args:
       WWWGROUP: '${GID}'
   image: sail-8.0/app
   extra_hosts:
     - 'host.docker.internal:host-gateway'
   environment:
     WWWUSER: '${UID}'
   volumes:
     - './microservice-d/:/var/www/html'
   networks:
     - php_microservices_demo

这个文件指定了容器将共享的网络为php_microservices_demo ,你将需要创建这个网络。现在通过在终端运行docker network create php_microservices_demo 来完成这个工作。接下来,将你当前的用户和组ID作为环境变量暴露给Bash,这样Bash就可以在构建镜像时映射正确的权限。要做到这一点,在你的终端中运行以下命令。

export UID=${UID:-$(id -u)} 
export GID=${GID:-$(id -g)}

上述命令可能会返回一个关于只读变量的警告,这取决于你的操作系统。如果出现这种情况,你可以安全地忽略它。

现在, 如果你在教程根目录下运行docker-compose up -d, 一会儿后, 你应该可以访问api-gateway Laravel项目,地址是http://localhost:8080

在你实现认证之前, 你需要做一些简单的模拟API, 这样你就有东西可以测试.要做到这一点,你可以为四个microservice 应用程序中的每一个定义一个路由--类似于api/service/,并让这个路由返回一个字符串,表明它是哪个微服务。然后,API网关可以定义多个路由,将请求适当地转发给微服务。因为API网关是docker-compose 文件中唯一有端口映射的容器,这将是你访问内部微服务的唯一途径。要做到这一点, 为每个相应的Laravel实例的routes/api.php 文件添加以下路由:

API网关:

Route::get('/service1', function(Request $request) {
   $response = \Illuminate\Support\Facades\Http::get('http://microservice-a/api/service');
   return new \Illuminate\Http\Response($response->body(), $response->status());
});

Route::get('/service2', function(Request $request) {
   $bearer = $request->bearerToken();
   $response = \Illuminate\Support\Facades\Http::withToken($bearer)->get('http://microservice-b/api/service');
   return new \Illuminate\Http\Response($response->body(), $response->status());
});

Route::get('/service3', function(Request $request) {
   $bearer = $request->bearerToken();
   $response = \Illuminate\Support\Facades\Http::withToken($bearer)->get('http://microservice-c/api/service');
   return new \Illuminate\Http\Response($response->body(), $response->status());
});

微服务A:

​​Route::get('/service', function (Request $request) {
   return \Illuminate\Support\Facades\Http::get('http://microservice-d/api/service');
});

微服务B:

Route::get('/service', function (Request $request) {
   return new \Illuminate\Http\Response('success response from microservice b');
});

微服务C:

​​Route::get('/service', function (Request $request) {
   return new \Illuminate\Http\Response('success response from microservice c');
});

微服务 D:

Route::get('/service', function (Request $request) {
   return new \Illuminate\Http\Response('success response from microservice d');
});

现在,如果你在浏览器或Postman中导航到http://localhost:8080/api/service1 ,你应该看到来自你的一个微服务通过API网关的响应。有了这些,你就可以实现你的第一个认证模式了。

网关上的JWT验证

第一个模式是在API网关上进行简单的JWT验证。所有通过网关的API请求都需要一个有效的JWT访问令牌。要做到这一点,你可以使用Laravel中间件。中间件是指你可以配置的代码,当收到请求时,但在控制器或路由定义指定的函数可以处理它之前运行。在实践中, 这意味着你可以有一个中间件组件来检查有效的访问令牌,如果没有找到有效的令牌,则返回一个未经授权的响应。

正如前面提到的,你可以使用Laravel Sail在你的容器内执行php artisan 命令。要做到这一点, 导航到包含你想运行命令的代码库的目录-api-gateway, 在这个例子中-并运行./vendor/bin/sail up 。你将在你的系统上创建更多的容器。虽然它们对实际的API并不重要,但容器提供了一种方便的方式来执行PHP命令而不需要定制工具。在这一点上,你也应该为Sail创建一个别名:alias sail=./vendor/bin/sail 。为了简洁起见,本教程中其余的Sail命令将假设你已经配置了这样一个别名。

在另一个终端中运行sail up ,然后导航到相同的api-gateway 目录,运行sail php artisan make:middleware VerifyJwt 。这个动作将创建网关使用的中间件,为传入的请求检查访问令牌。当你在这里时,你还需要安装两个 composer 依赖项来验证 JWTs。要做到这一点,请运行以下命令。sail composer require okta/jwt-verifier firebase/php-jwt.在充实新创建的中间件之前,你需要快速绕道Okta开发者门户,创建一个应用程序,以便你的中间件拥有验证传入的JWTs所需的细节。

进入developer.okta.com,登录或建立一个新的账户。接下来,在侧边栏上导航到应用程序,点击创建应用程序集成

Screenshot of creating a new app integration with Okta using OIDC

选择一个新的OIDC集成的 "单页应用 "应用类型,然后点击下一步。填写你的新应用集成的细节,选择一个名字来帮助你识别它,将 "授予类型 "设置为 "授权代码",将 "受控访问 "设置为 "允许你的组织中的每个人访问"。

注意:我们在整个教程中使用一个单一的客户ID,以保持事情简单。在实践中,你可能希望为你的前端应用程序和你的每个微服务使用单独的客户端ID,以便你有更好的审计日志并能更好地控制访问。

Screenshot showing the new API services integration in Okta

一旦这些步骤完成,你会收到一个显示你的客户ID和Okta域名的页面。把这两样东西记下来,以便以后使用。

接下来,导航到你创建的中间件--api-gateway/app/Http/Middleware/VerifyJwt.php--并按以下方式设置其内容。

<?php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Okta\JwtVerifier\Adaptors\FirebasePhpJwt;
use Okta\JwtVerifier\JwtVerifierBuilder;

class VerifyJwt
{
   /**
      * Handle an incoming request.
      *
      * @param  \Illuminate\Http\Request  $request
      * @param  \Closure  $next
      * @return mixed
      */
   public function handle(Request $request, Closure $next)
   {

      $jwtVerifier = (new JwtVerifierBuilder())
       	->setAdaptor(new FirebasePhpJwt())
       	->setAudience(env('OKTA_AUDIENCE'))
       	->setClientId(env('OKTA_CLIENT_ID'))
       	->setIssuer(env('OKTA_ISSUER_URI'))
       	->build();

      try {
       	$jwt = $jwtVerifier->verify($request->bearerToken());
       	return $next($request);
      } catch (\Exception $exception) {
       	Log::error($exception);
      }

      return response('Unauthorized', 401);
   }
}

然后转到网关的.env 文件,并添加以下值。

OKTA_AUDIENCE=api://default
OKTA_ISSUER_URI=https://{your okta domain}/oauth2/default
OKTA_CLIENT_ID={your client id}

接下来,导航到api-gateway/app/Http/Kernel.php ,找到$middlewareGroups 阵列。应该有一个'api' 的键和一个相应的数组。在这个数组中,添加\App\Http\Middleware\VerifyJwt::class 作为一个新项目。这个动作将导致中间件为API组中的任何路由运行,包括你之前创建的路由,以允许用户访问微服务。你可以通过使用Postman或你的浏览器访问先前的相同路由来验证这一点--http://localhost:8080/api/service1 --但你现在应该遇到一个未经授权的错误,因为你还没有一个JWT。

为了获得JWT,打开Postman并创建一个新的GET请求。打开授权标签,将类型改为 "OAuth 2.0",并将添加授权数据设置为请求头。接下来,向下滚动到 "配置新令牌 "部分,给它起个名字,比如 "PHP微服务"。这个名字除了影响Postman的显示方式外,并不影响任何东西。设置其余的值如下。

  • 授权类型: 授权代码 (With PKCE)
  • 回调URLhttp://localhost:8080/login/callback
  • Auth URL: https://{{你的okta域名}}/oauth2/default/v1/authorize
  • 访问令牌URL: https://{{你的okta域名}}/oauth2/default/v1/token
  • 客户端ID: {{你的客户ID}}。
  • 范围: openid email
  • 状态: 1234
  • 客户端认证: 正文中发送客户凭证

设置好这些细节后,当您点击获取新访问令牌时,您应该看到一个Okta登录窗口。用您的Okta开发者账户信息登录,当程序完成后,您应该看到您的新令牌。点击 "使用令牌",为这个请求设置它。

Screenshot showing Access Token management in Okta

如果你还没有这样做,通过将URL设置为http://localhost:8080/api/service1, 完成请求的创建,并点击 发送 。经过短暂的延迟,你应该从API网关得到一个从你的一个微服务转发的响应。好样的!你已经成功实现了第一个认证模式。在这种模式下,网关会验证JWT,而微服务A不做进一步的验证。它假定网关已经处理了验证。这种方法只有在底层微服务不能通过互联网公开访问的情况下才是可行的。当微服务必须通过网关访问时,它的效果就很好,这里就是这样。

JWT范围验证

微服务B将执行它自己的本地JWT令牌验证,并寻找令牌上的一个特定范围。如果这个范围不存在,它将拒绝该令牌,即使网关已经接受了它。

你的第一步是为微服务B添加一个自定义范围来寻找。在Okta门户上,导航到侧边栏的安全>API,从列表中选择默认的认证服务器。转到作用域标签,点击添加作用域。给它起个名字,比如microservice-demo-scope ,然后像这样填写其余的细节。

Screenshot showing how to add a new scope in Okta

在下一步,你将添加微服务B的中间件。如果sail up 仍在为网关运行,终止这个命令(在macOS上为Control + C),并将目录改为Microservice B。再次运行sail up 。在另一个终端,导航到Microservice B的目录。 现在运行以下命令。

sail composer require okta/jwt-verifier firebase/php-jwt
sail php artisan make:middleware VerifyJwtWithScope

一旦你运行了这些命令,打开位于microservice-b/app/Http/Middleware/VerifyJwtWithScope.php 的新中间件,并按以下方式设置内容。

<?php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Log;
use Okta\JwtVerifier\Adaptors\FirebasePhpJwt;
use Okta\JwtVerifier\JwtVerifierBuilder;
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;

class VerifyJwtWithScope
{
   /**
      * Handle an incoming request.
      *
      * @param  \Illuminate\Http\Request  $request
      * @param  \Closure  $next
      * @return mixed
      */
   public function handle(Request $request, Closure $next)
   {

      $jwtVerifier = (new JwtVerifierBuilder())
       	->setAdaptor(new FirebasePhpJwt())
       	->setAudience(env('OKTA_AUDIENCE'))
       	->setClientId(env('OKTA_CLIENT_ID'))
       	->setIssuer(env('OKTA_ISSUER_URI'))
       	->build();

   	// Microservice B verifies the token itself, and looks for a specific scope
      try {
       	$jwt = $jwtVerifier->verify($request->bearerToken());

       	$scopes = Arr::get($jwt->claims, 'scp', []);
       	$requiredScope = 'microservice-demo-scope';

       	if (!in_array($requiredScope, $scopes)) {
           throw new UnauthorizedHttpException('missing required scope');
       	}

       	return $next($request);
      } catch (\Exception $exception) {
       	Log::error($exception);
      }

      return response('Unauthorized', 401);
   }
}

注意,在验证了JWT之后,你可以提取其索赔。scp 索赔将包含作用域,然后你可以检查是否存在你新创建的作用域。

在微服务B的Kernel.php 文件中注册这个中间件,就像你对API网关所做的那样,并在微服务B的.env 文件中添加以下内容。

OKTA_AUDIENCE=api://default
OKTA_ISSUER_URI=https://{your okta domain}/oauth2/default
OKTA_CLIENT_ID={your client id}

如果你去Postman,在发射请求时将你的请求的URL改为指向/service2 ,而不是/service1 ,它应该会失败,因为你的token缺少所需的范围。回到Postman的Auth选项卡,向下滚动到你指定作用域的地方。添加你的新作用域,使其成为 "openid email microservice-demo-scope",并请求一个新的令牌。一旦你选择使用令牌,你对微服务B的请求现在应该成功了。

远程令牌自省

要实现的第三个模式是针对微服务C的。在这个模式中,微服务将再次对令牌进行自己的验证。这一次,它将调用Okta/introspect 端点来断言令牌没有被撤销。如果这个端点说令牌没有问题,请求将继续进行;否则,请求将被阻止。

停止前面模式中正在运行的sail up 工作,并在再次运行sail up 之前将目录改为微服务C。在你的另一个终端,从微服务C运行以下命令:sail php artisan make:middleware VerifyJwtWithIntrospection 。打开新创建的中间件文件,对其内容进行如下设置。

<?php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Validation\UnauthorizedException;

class VerifyJwtWithIntrospection
{
   /**
      * Handle an incoming request.
      *
      * @param  \Illuminate\Http\Request  $request
      * @param  \Closure  $next
      * @return mixed
      */
   public function handle(Request $request, Closure $next)
   {

      $accessToken = $request->bearerToken();
      $accessTokenType = 'access_token';

      $oktaDomain = env('OKTA_DOMAIN');
      $oktaClientId = env('OKTA_CLIENT_ID');

      try {
       	// make api call to introspect endpoint
       	$introspectionResponse = Http::asForm()->post("$oktaDomain/oauth2/default/v1/introspect?client_id=$oktaClientId", [
           	'token' => $accessToken,
           	'token_type_hint' => $accessTokenType
       	]);

       	$isTokenActive = $introspectionResponse->json('active');

       	if (!$isTokenActive) {
           throw new UnauthorizedException('token is invalid');
       	}

      } catch (\Exception $exception) {
       	Log::error($exception);
       	return new Response('Unauthorized - Token failed Introspection', 401);
      }

      return $next($request);
   }
}

在Microservice C的内核中注册这个中间件,并为这个微服务添加以下内容:.env

OKTA_DOMAIN=https://{your okta domain}
OKTA_CLIENT_ID={your client id}

现在,当你调用网关的/service3 端点时,它将把请求转发给微服务C,随后它将执行反省,看令牌是否被撤销。你可以通过从Postman获取你的访问令牌,然后用Okta的revoke端点撤销它来看到这个动作。这将导致在使用该令牌时对/service3 的后续请求失败。如果你用这个令牌调用/service1 ,你会发现它仍然可以工作,因为这个服务只做本地的JWT验证,不知道这个令牌已经在其他地方被撤销。

客户端凭证授予

要实现的最后一个模式是针对微服务D的。在这里,你将实现客户证书流程。微服务D将验证它自己的令牌,但用户提供的令牌将是不充分的。微服务D不能通过网关直接访问;相反,它是由微服务A调用的。为了使这个流程工作,微服务A将需要使用客户端凭证授予请求一个令牌,并使用该令牌向微服务D发出请求。

像以前一样,停止任何正在运行的sail up 命令,导航到微服务D,并运行sail up 。在另一个终端,导航到Microservice D,并运行这些命令。

sail composer require okta/jwt-verifier firebase/php-jwt
sail php artisan make:middleware VerifyClientCredentialsToken

设置新的中间件的内容如下,然后在Microservice D的内核中注册它。

​​<?php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Okta\JwtVerifier\Adaptors\FirebasePhpJwt;
use Okta\JwtVerifier\JwtVerifierBuilder;

class VerifyClientCredentialsToken
{
   /**
      * Handle an incoming request.
      *
      * @param \Illuminate\Http\Request $request
      * @param \Closure $next
      * @return mixed
      */
   public function handle(Request $request, Closure $next)
   {

      $jwtVerifier = (new JwtVerifierBuilder())
       	->setAdaptor(new FirebasePhpJwt())
       	->setAudience(env('OKTA_AUDIENCE'))
       	->setClientId(env('OKTA_CLIENT_ID'))
       	->setIssuer(env('OKTA_ISSUER_URI'))
       	->build();

      try {
       	$jwtVerifier->verify($request->bearerToken());
       	return $next($request);
      } catch (\Exception $exception) {
       	Log::error($exception);
      }

      return response('Unauthorized', 401);
   }
}

接下来,回到Okta开发者门户,进入侧边栏的应用程序。创建另一个应用集成,选择API服务作为登录方式。给它起个名字,然后点击保存

New API services integration with Okta

新的集成将提供一个新的客户ID和密码。记下这些。接下来,回到 "安全">"API",选择默认的认证服务器,并添加另一个作用域--这次叫它 "机器-作用域 "之类的。这将成为微服务A请求其令牌时的自定义范围。

在微服务D的.env 文件中,添加以下内容。

OKTA_CLIENT_ID={your new client id from the API services integration}
OKTA_AUDIENCE=api://default
OKTA_ISSUER_URI=https://{your okta domain}/oauth2/default

现在你应该发现,Postman对/service1 的调用将失败,因为微服务A还不能与微服务D进行认证。为了解决这个问题,你需要更新微服务A中的API路由,以使用客户凭证授予来获得一个新的令牌。打开microservice-a/routes/api.php ,修改内容如下。

Route::get('/service', function (Request $request) {

   // Microservice A needs to request a new token using the Client Credentials flow, and use that to authenticate with Microservice D.

   $customScope = 'machine-scope'; // This is the scope we created in Okta for our default auth server.

   // The details for our machine-to-machine application integration
   $clientId = env('OKTA_CLIENT_ID');
   $secret = env('OKTA_SECRET');

   $oktaDomain = env('OKTA_DOMAIN');

   $tokenResponse = \Illuminate\Support\Facades\Http::withBasicAuth($clientId, $secret)
   	->asForm()
   	->post("$oktaDomain/oauth2/default/v1/token", [
       	'grant_type' => 'client_credentials',
       	'scope' => $customScope
   	]);

   $token = $tokenResponse->json('access_token');

   return \Illuminate\Support\Facades\Http::withToken($token)->get('http://microservice-d/api/service');
});

接下来,更新微服务A的.env 文件,包括这些细节。

OKTA_CLIENT_ID={your new client credentials client Id}
OKTA_SECRET={your new client credentials secret}
OKTA_DOMAIN=https://{your okta domain}

有了这些细节,/service1 应该再次工作,因为它将获得自己的令牌并使用它与微服务D通信。

结语

如果你一直跟着代码走,你现在应该有一个有三个端点的API网关,由四个底层微服务支持,有四个独特的认证模式。你已经看到了API网关所使用的基本本地JWT验证。这是最快的方法,但它无法捕捉被撤销的令牌。接下来是微服务B,它检查的是一个特定的范围。这种模式与第一种方法类似,但围绕哪些用户可以根据他们的令牌所附的作用域执行特定的操作,提供了更细化的控制。最后,你看到了如何用远程自省来验证一个令牌。这种方法更加稳健,因为它能够捕获被撤销的令牌,但它的缺点是与本地JWT验证相比,需要额外的HTTP调用。

希望本教程能让你了解用PHP微服务处理认证的不同方式。所有这些模式都得到了Okta的支持,还有很多其他模式。Okta提供了大量的认证工具和服务,使你的应用程序添加世界级的认证变得非常容易。