乐趣区

Swoft 系列教程:(2)认证服务及组件

Swoft 提供了一整套认证服务组件,基本做到了配置后开箱即用。用户只需根据自身业务实现相应的登录认证逻辑,框架认证组件会调用你的登录业务进行 token 的签发,而后的请求中 token 解析、合法性验证也都由框架提供,同时框架开放了 token 权限认证接口给用户,我们需根据自身业务实现 token 对当前访问资源权限的认证。下面我们详细讲一下 jwt 的签发及验证、访问控制的流程。

token 签发
token 签发的基本流程为请求用户登录认证服务,认证通过则签发 token。Swoft 的认证组件为我们完成了 token 签发工作,同时 Swoft 约定了一个 Swoft\Auth\Mapping\AuthManagerInterface::login 方法作为用户的认证业务的入口。
使用到的组件及服务:
#认证组件服务,使用此接口类名作为服务名注册到框架服务中
`Swoft\Auth\Mapping\AuthManagerInterface::class`
#框架认证组件的具体实现者 token 的签发、合法校验、解析
`Swoft\Auth\AuthManager`
#token 的会话载体 存储着 token 的信息
`Swoft\Auth\Bean\AuthSession`

# 约定用户的认证业务需实现返回 `Swoft\Auth\Bean\AuthResult` 的 `login` 方法和 `bool` 的 `authenticate` 的方法
`Swoft\Auth\Mapping\AccountTypeInterface`
#用于签发 token 的必要数据载体 iss/sub/iat/exp/data 传递给 `Swoft\Auth\AuthManager` 签发 token
`Swoft\Auth\Bean\AuthResult`

配置项:config/properties/app.php 设定 auth 模式 jwt
return [

‘auth’ => [
‘jwt’ => [
‘algorithm’ => ‘HS256’,
‘secret’ => ‘big_cat’
],
]

];
config/beans/base.php 为 \Swoft\Auth\Mapping\AuthManagerInterface::class 服务绑定具体的服务提供者
return [
‘serverDispatcher’ => [
‘middlewares’ => [

],

],
// token 签发及合法性验证服务
\Swoft\Auth\Mapping\AuthManagerInterface::class => [
‘class’ => \App\Services\AuthManagerService::class
],
];

App\Models\Logic\AuthLogic
实现用户业务的认证,以 Swoft\Auth\Mapping\AccountTypeInterface 接口的约定实现了 login/authenticate 方法。
login 方法返回 Swoft\Auth\Bean\AuthResult 对象,存储用于 jwt 签发的凭证:

setIdentity 对应 sub,即 jwt 的签发对象,一般使用 uid 即可

setExtendedData 对应 payload,即 jwt 的载荷,存储一些非敏感信息即可

authenticate 方法签发时用不到,主要在验证请求的 token 合法性时用到,即检测 jwt 的 sub 是否为本平台合法用户
<?php
namespace App\Models\Logic;

use Swoft\Auth\Bean\AuthResult;
use Swoft\Auth\Mapping\AccountTypeInterface;

class AuthLogic implements AccountTypeInterface
{
/**
* 用户登录认证 需返回 AuthResult 对象
* 返回 Swoft\Auth\Bean\AuthResult 对象
* @override Swoft\Auth\Mapping\AccountTypeInterface
* @param array $data
* @return AuthResult
*/
public function login(array $data): AuthResult
{
$account = $data[‘account’];
$password = $data[‘password’];

$user = $this->userDao->getByConditions([‘account’ => $account]);

$authResult = new AuthResult();

// 用户验证成功则签发 token
if ($user instanceof User && $this->userDao->verifyPassword($user, $password)) {
// authResult 主标识 对应 jwt 中的 sub 字段
$authResult->setIdentity($user->getId());
// authResult 附加数据 jwt 的 payload
$authResult->setExtendedData([self::ID => $user->getId()]);
}

return $authResult;
}

/**
* 验证签发对象是否合法 这里我们简单验证签发对象是否为本平台用户
* $identity 即 jwt 的 sub 字段
* @override Swoft\Auth\Mapping\AccountTypeInterface
* @param string $identity token sub 字段
* @return bool
*/
public function authenticate(string $identity): bool
{
return $this->userDao->exists($identity);
}
}
Swoft\Auth\AuthManager::login 要求传入用户业务的认证类,及相应的认证字段,根据返回 Swoft\Auth\Bean\AuthResult 对象判断登录认证是否成功,成功则签发 token,返回 Swoft\Auth\Bean\AuthSession 对象。

App\Services\AuthManagerService
用户认证管理服务,继承框架 Swoft\Auth\AuthManager 做定制扩展。比如我们这里实现一个 auth 方法供登录请求调用,auth 方法中则传递用户业务认证模块来验证和签发 token,获取 token 会话数据。
<?php
/**
* 用户认证服务
* User: big_cat
* Date: 2018/12/17 0017
* Time: 16:36
*/

namespace App\Services;

use App\Models\Logic\AuthLogic;

use Swoft\Redis\Redis;

use Swoft\Bean\Annotation\Bean;
use Swoft\Bean\Annotation\Inject;

use Swoft\Auth\AuthManager;
use Swoft\Auth\Bean\AuthSession;
use Swoft\Auth\Mapping\AuthManagerInterface;

/**
* @Bean()
* @package App\Services
*/
class AuthManagerService extends AuthManager implements AuthManagerInterface
{
/**
* 缓存类
* @var string
*/
protected $cacheClass = Redis::class;

/**
* jwt 具有自包含的特性 能自己描述自身何时过期 但只能一次性签发
* 用户主动注销后 jwt 并不能立即失效 所以我们可以设定一个 jwt 键名的 ttl
* 这里使用是否 cacheEnable 来决定是否做二次验证
* 当获取 token 并解析后,token 的算法层是正确的 但如果 redis 中的 jwt 键名已经过期
* 则可认为用户主动注销了 jwt,则依然认为 jwt 非法
* 所以我们需要在用户主动注销时,更新 redis 中的 jwt 键名为立即失效
* 同时对 token 刷新进行验证 保证当前用户只有一个合法 token 刷新后前 token 立即失效
* @var bool 开启缓存
*/
protected $cacheEnable = true;

// token 有效期 7 天
protected $sessionDuration = 86400 * 7;

/**
* 定义登录认证方法 调用 Swoft 的 AuthManager@login 方法进行登录认证 签发 token
* @param string $account
* @param string $password
* @return AuthSession
*/
public function auth(string $account, string $password): AuthSession
{
// AuthLogic 需实现 AccountTypeInterface 接口的 login/authenticate 方法
return $this->login(AuthLogic::class, [
‘account’ => $account,
‘password’ => $password
]);
}
}

App\Controllers\AuthController
处理用户的登录请求
<?php
/**
* Created by PhpStorm.
* User: big_cat
* Date: 2018/12/10 0010
* Time: 17:05
*/
namespace App\Controllers;

use App\Services\AuthManagerService;

use Swoft\Http\Message\Server\Request;
use Swoft\Http\Server\Bean\Annotation\Controller;
use Swoft\Http\Server\Bean\Annotation\RequestMapping;
use Swoft\Http\Server\Bean\Annotation\RequestMethod;

use Swoft\Bean\Annotation\Inject;
use Swoft\Bean\Annotation\Strings;
use Swoft\Bean\Annotation\ValidatorFrom;

/**
* 登录认证模块
* @Controller(“/v1/auth”)
* @package App\Controllers
*/
class AuthController
{
/**
* 用户登录
* @RequestMapping(route=”login”, method={RequestMethod::POST})
* @Strings(from=ValidatorFrom::POST, name=”account”, min=6, max=11, default=””, template=” 帐号需 {min}~{max} 位, 您提交的为{value}”)
* @Strings(from=ValidatorFrom::POST, name=”password”, min=6, max=25, default=””, template=” 密码需 {min}~{max} 位, 您提交的为{value}”)
* @param Request $request
* @return array
*/
public function login(Request $request): array
{
$account = $request->input(‘account’) ?? $request->json(‘account’);
$password = $request->input(‘password’) ?? $request->json(‘password’);

// 调用认证服务 – 登录 & 签发 token
$session = $this->authManagerService->auth($account, $password);

// 获取需要的 jwt 信息
$data_token = [
‘token’ => $session->getToken(),
‘expired_at’ => $session->getExpirationTime()
];

return [
“err” => 0,
“msg” => ‘success’,
“data” => $data_token
];
}
}
POST /v1/auth/login 的结果
{
“err”: 0,
“msg”: “success”,
“data”: {
“token”: “eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJBcHBcXE1vZGVsc1xcTG9naWNcXEF1dGhMb2dpYyIsInN1YiI6IjgwIiwiaWF0IjoxNTUxNjAyOTk4LCJleHAiOjE1NTIyMDc3OTgsImRhdGEiOnsidWlkIjo4MH19.u2g5yU9ir1-ETVehLFIIZZgtW7u9aOvH2cndMsIY98Y”,
“expired_at”: 1552207798
}
}

这里提及一下为什么要提供在服务端缓存 token 的选项 $cacheEnable。

普通的 token 不像 jwt 具有自我描述的特性,我们为维护 token 的有效期只能在服务端缓存其有效期,防止过期失效的 token 被滥用。

jwt 可以自我描述过期时间,为什么也要缓存呢?因为 jwt 自身的描述是只读的,即我们无法让 jwt 提前过期失效,如果用户退出登录,则销毁 token 是个不错的安全开发习惯,所以只有在服务端也维护了一份 jwt 的过期时间,用户退出时过期此 token,那么就可以自由控制 jwt 的过期时间。

/**
* @param string $token
* @return bool
*/
public function authenticateToken(string $token): bool
{

// 如果开启了服务端缓存选项 则验证 token 是否过期 可变向控制 jwt 的有效期
if ($this->cacheEnable === true) {
try {
$cache = $this->getCacheClient()
->get($this->getCacheKey($session->getIdentity(), $session->getExtendedData()));
if (! $cache || $cache !== $token) {
throw new AuthException(ErrorCode::AUTH_TOKEN_INVALID);
}
} catch (InvalidArgumentException $e) {
$err = sprintf(‘Identity : %s ,err : %s’, $session->getIdentity(), $e->getMessage());
throw new AuthException(ErrorCode::POST_DATA_NOT_PROVIDED, $err);
}
}

$this->setSession($session);
return true;
}

token 解析、验证
token 的解析及合法性验证实现流程,注意只是验证 token 的合法性,即签名是否正确,签发者,签发对象是否合法,是否过期。并未对 token 的访问权限做认证。

使用到的组件及服务:
#调用 `token 拦截服务 ` 尝试获取 `token`,并调用 `token 管理服务 ` 做解析及合法性验证
`Swoft\Auth\Middleware\AuthMiddleware`

#`token 拦截服务 `
`Swoft\Auth\Mapping\AuthorizationParserInterface::class`
#`token 拦截服务提供者 `,根据 `token 类型 ` 调用相应的 `token 解析器 `
`Swoft\Auth\Parser\AuthorizationHeaderParser`

#`token 管理服务 `,由 `token 管理服务提供者 ` 提供基础服务,被 `token 解析器 ` 调用
`Swoft\Auth\Mapping\AuthManagerInterface::class`
#`token 管理服务提供者 `,负责签发、解析、合法性验证
`Swoft\Auth\AuthManager`

Swoft\Auth\Middleware\AuthMiddleware 负责拦截请求并调用 token 解析及验证服务。会尝试获取请求头中的 Authorization 字段值,根据类型 Basic/Bearer 来选择相应的权限认证服务组件对 token 做合法性的校验并生成 token 会话。但并不涉及业务访问权限 ACL 的验证,即只保证某个 token 是本平台合法签发的,不保证此 token 对当前资源有合法的访问权限。如果 Authorization 为空的话则视为普通请求。
执行流程:

Swoft\Auth\Middleware\AuthMiddleware 调用 Swoft\Auth\Mapping\AuthorizationParserInterface::class 服务,服务具体由 Swoft\Auth\Parser\AuthorizationHeaderParser 实现。
服务 AuthorizationHeaderParser 尝试获取请求头中的 Authorization 字段值,如果获取到 token,则根据 token 的类型:BasicorBearer 来调用具体的解析器。Basic 的解析器为 `Swoft\Auth\Parser\Handler::BasicAuthHandler,Bearer 的解析器为 Swoft\Auth\Parser\Handler::BearerTokenHandler,下面我们具体以 Bearer 模式的 jwt 为示例。
在获取到类型为 Bearer 的 token 后,BearerTokenHandler 将会调用 Swoft\Auth\Mapping\AuthManagerInterface::class 服务的 authenticateToken 方法来对 token 进行合法性的校验和解析,即判断此 token 的签名是否合法,签发者是否合法,签发对象是否合法(注意:调用了 App\Models\Logic\AuthLogic::authenticate 方法验证),是否过期等。

token 解析验证非法,则抛出异常中断请求处理。

token 解析验证合法,则将 payload 载入本次会话并继续执行。

所以我们可以将此中间件注册到全局,请求携带 token 则解析验证,不携带 token 则视为普通请求。

#config/beans/base.php
return [
‘serverDispatcher’ => [
‘middlewares’ => [

\Swoft\Auth\Middleware\AuthMiddleware::class
],

],
// token 签发及合法性验证服务
\Swoft\Auth\Mapping\AuthManagerInterface::class => [
‘class’ => \App\Services\AuthManagerService::class
],
];
<?phpnamespace AppModelsLogic;
use SwoftAuthBeanAuthResult;use SwoftAuthMappingAccountTypeInterface;
class AuthLogic implements AccountTypeInterface{

/**
* 验证签发对象是否合法 这里我们简单验证签发对象是否为本平台用户
* $identity 即 jwt 的 sub 字段
* @override Swoft\Auth\Mapping\AccountTypeInterface
* @param string $identity token sub 字段
* @return bool
*/
public function authenticate(string $identity): bool
{
return $this->userDao->exists($identity);
}
}

acl 鉴权
token 虽然经过了合法性验证,只能说明 token 是本平台签发的,还无法判断此 token 是否有权访问当前业务资源,所以我们还要引入 Acl 认证。
使用到的组件及服务:
#Acl 认证中间件
Swoft\Auth\Middleware\AclMiddleware

# 用户业务权限 auth 服务
Swoft\Auth\Mapping\AuthServiceInterface::class

#token 会话访问组件
Swoft\Auth\AuthUserService

Swoft\Auth\Middleware\AclMiddleware 中间件会调用 Swoft\Auth\Mapping\AuthServiceInterface::class 服务,此服务主要用于 Acl 认证,即验证当前请求是否携带了合法 token,及 token 是否对当前资源有访问权限。

Swoft\Auth\Mapping\AuthServiceInterface::class 服务由框架的 Swoft\Auth\AuthUserService 组件实现获取 token 会话的部分功能,auth 方法则交由用户层重写,所以我们需继承 Swoft\Auth\AuthUserService 并根据自身业务需求实现 auth 方法。
在继承了 Swoft\Auth\AuthUserService 的用户业务认证组件中,我们可以尝试获取 token 会话的签发对象及 payload 数据:getUserIdentity/getUserExtendData。然后在 auth 方法中判断当前请求是否有 token 会话及是否对当前资源有访问权限,来决定返回 true or false 给 AclMiddleware 中间件。

AclMiddleware 中间件获取到用户业务下的 auth 为 false(请求没有携带合法 token 401 或无权访问当前资源 403),则终端请求处理。

AclMiddleware 中间件获取到在用户业务下的 auth 为 true,则说明请求携带合法 token,且 token 对当前资源有权访问,继续请求处理。

config/bean/base.php
return [
‘serverDispatcher’ => [
‘middlewares’ => [
….
// 系统 token 解析中间件
\Swoft\Auth\Middleware\AuthMiddleware::class,

]
],
// token 签发及合法性验证服务
\Swoft\Auth\Mapping\AuthManagerInterface::class => [
‘class’ => \App\Services\AuthManagerService::class
],
// Acl 用户资源权限认证服务
\Swoft\Auth\Mapping\AuthServiceInterface::class => [
‘class’ => \App\Services\AclAuthService::class,
‘userLogic’ => ‘${‘ . \App\Models\Logic\UserLogic::class . ‘}’ // 注入 UserLogicBean
],
];

App\Services\AclAuthService
对 token 做 Acl 鉴权。
<?php
namespace App\Services;

use Swoft\Auth\AuthUserService;
use Swoft\Auth\Mapping\AuthServiceInterface;
use Psr\Http\Message\ServerRequestInterface;

/**
* Bean 因在 config/beans/base.php 中已经以参数配置的方式注册,故此处不能再使用 Bean 注解声明
* Class AclAuthService
* @package App\Services
*/
class AclAuthService extends AuthUserService implements AuthServiceInterface
{
/**
* 用户逻辑模块
* 因本模块是以参数配置的方式注入到系统服务的
* 所以其相关依赖也需要使用参数配置方式注入 无法使用 Inject 注解声明
* @var App\Models\Logic\UserLogic
*/
protected $userLogic;

/**
* 配合 AclMiddleware 中间件 验证用户请求是否合法
* true AclMiddleware 通过
*false AclMiddleware throw AuthException
* @override AuthUserService
* @param string $requestHandler
* @param ServerRequestInterface $request
* @return bool
*/
public function auth(string $requestHandler, ServerRequestInterface $request): bool
{
// 签发对象标识
$sub = $this->getUserIdentity();
// token 载荷
$payload = $this->getUserExtendData();

// 验证当前 token 是否有权访问业务资源 aclAuth 为自己的认证逻辑
if ($this->aclAuth($sub, $payload)) {
return true;
}
return false;
}
}

退出移动版