统一处理「认证+授权」

认证:Authentication;授权:Authorization。

说明,以下内容,实际是根据一个 API 项目来说的,但是可以作为认证+授权实现的一种参考。

为什么要认证+授权机制?

服务端只进行对 API token 的校验,只完成了认证一个用户是否是我们系统合法用户的问题,但没有解决多个合法用户之间可以互相获取、操作彼此数据的问题。

出于对安全性和用户数据私有性的考虑,以及方便统一管理用户权限,需要有这么一个认证+授权机制,同时解决以下问题:

  • 认证(一级):API Token 是否合法?

  • 判断(二级):是否允许已认证过的用户访问当前所请求的路由(根据method+path)?

  • 判断(三级):已认证的用户是否能访问「路由参数」中对应的模型对象?

  • 简单:大部分情况下,最好能只通过少量的配置和一些约定,在一个地方解决上面三个问题

  • 扩展性:假设日后需要出现四级权限判断等需求,无需通过修改现有代码,而是通过线性增加方法数来实现。

为什么不直接使用 Laravel 提供的 Authorization?

简单来讲,只是为了尝试用最少的代码解决等量的问题,具体来说主要分两个方面:

  • 在一个地方认证+授权统一处理,不需要引入多个中间件

  • 为了在大部分使用场景下,简少在控制器中出现权限判断代码的比例

Laravel 提供的 Authorization 机制能解决问题,但是主要有两个不方便的地方:

  • 认证和授权分开

意味着两种校验代码分散开,要么引入两种中间件,要么在控制器中各自写各自的判断逻辑。

而在绝大部分情况下,授权是建立在对已认证用户的前提下,即没必要分开。

  • 要保证授权能正常工作,必须事先注册一堆 Policy(或者定义一堆 Gate)

这是比较繁琐的一些步骤,比如我想要判断已认证的用户是否能更新另一个用户的私有资源,我必须这么做:

  • 创建一个 ReourcePolicy 类,里面定义一个更新方法 update()

  • 注册该 ReourcePolicy 到 AuthServiceProvider

  • 在控制器等地方手动编写判断当前用户是否具备 update 操作的权限的代码

这意味着,随着项目规模地增大,权限数量的增多,权限判断代码会随处可见。

这里不比较关于定义权限的代码量,因为权限定义的代码在项目权限机制中属于原子级基础设施,无法避免。

如何使用认证+授权中间件

以更新用户个人信息操作举例说明:

定义符合约定的 REST 路由

1
PUT|PATCH /members/{member}

假设当前认证的用户 ID 是 1,而如果他传递给路由参数 {member} 是 2,这时应该返回 403 禁止该操作。

即,对于同等权限的合法用户 1 和 2 来说,1 号用户只能通过 PUT| PATCH /members/1 来更新自己的信息,而无法更新其他用户的信息。

当然,具有类似超级管理员权限的角色可以通过该路由更新任何用户的信息,不过这里我们关心的同等权限的用户之间。

使用权限中间件

路由定义文件中主要定义如下:

1
2
3
4
5
6
7
8
$router->group([
'prefix' => 'members',
'middleware' => [
'permission:MemberOwner=member;address;binding&FooBar=xxx,yyy,zzz',
],
], function ($router) {
$router->put('{member}', 'Memeber@update');
});

权限定义格式

1
permission:{PERMISSION_KEY}={ROUTE_PARAM_NAME};{ROUTE_PARAM_NAME};...&{PERMISSION_KEY}={ROUTE_PARAM_NAME};{ROUTE_PARAM_NAME};...&...

中间件参数格式大体符合 x-www-form-urlencoded 规范。其中:

  • {PERMISSION_KEY}

代表的是权限的唯一标志,即前面 MemberOwner(代表会员本人才有的权限)。

该值的格式必须是和大写开头的驼峰命名规范。

一开始为了灵活支持小写 + 下划线等几种格式,但是在寻找权限定义代码的时候要多做一些字符串处理操作,后来删掉了,因为这个定义完全是后端开发者自定义的,可以通过约定少写一些判断代码,所以规定成这样。

  • {ROUTE_PARAM_NAME}

代表路由参数的在路由定义中使用的标志,比如 PUT|PATCH /members/{member} 中的 member 就是属于这种。

注意:只有路由中含有权限定义中包含的 KEY,才会对其进行进一步权限判断,否则直接放行

比如,GET /members/current 中不含任何路由参数,因此,权限中间件在检查的时候只会进行 Token 认证检查(一级)和路由本身的权限检查(二级),而不进行类似上面同等的合法用户之间的权限检查(三级)。

实际上没有路由参数,权限中间件也无法进行三级检查。

  • ;:路由参数KEY 分隔符

如果想要权限中间件检查多个路由参数,则各个路由参数之间需要用 ; 隔开。

比如上面的栗子中,同时会对 member/address/binding 这三个路由参数进行 MemberOwner 权限检查,而对 xxx/yyy/zzz 这三个路由参数进行 FooBar 权限检查。

至于为什么要使用 ;,主要是为了避免使用 URL 编码不友好的特殊字符串,同时 , 已经在 Laravel 中间件参数中用来分隔多个参数用了,其他没什么特殊原因。

定义权限(方法)

简单来说,给用户定义一种权限,就是给 App\Models\User 增加一种鉴权方法。

细节来说,鉴权方法必须以 authorize 开头,且返回值只能是布尔值。比如:

1
2
3
4
public function authorizeMemberOwnerWhenMember($member) : bool
{
return ($this->id == (Member::find($member)->user_id ?? -1));
}

可以发现,鉴权方法 authorizeMemberOwnerWhenMember 是由 authorize + {PERMISSION_KEY} + when + {ROUTE_PARAM_NAME} 组成的,且方法参数 $member 就是从路由上获取的实际的值。

因此,在路由定义时候,如果使用到了鉴权中间件,要确保对应的方法存在 User 模型中

但无需像 Laravel 提供的 Authorization 那样不管用不用到事先注册。

当然,鉴权方法不一定非写到 App/Models/User.php 中,为了方便管理,你可以使用 trait 或者其他方式将鉴权方法 mixin 到 User 模型中即可,比如我本人是在 App\Traits\UserPermissions 中写这些鉴权方法的。

附录:Permission

直接适用于 Laravel + Dingo Api 项目,其他项目参考思路。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
<?php

// Permission check along with basic api authentication
// @cjli

namespace App\Http\Middleware;

use Dingo\Api\Routing\Router;
use Dingo\Api\Auth\Auth;

use Symfony\Component\HttpKernel\Exception\{
BadRequestHttpException,
UnauthorizedHttpException
};

class Permission
{
protected $router = null;
protected $auth = null;
protected $_err = null;

public function __construct(Router $router, Auth $auth)
{
$this->router = $router;
$this->auth = $auth;
}

public function handle($request, \Closure $next, string $permission = null)
{
$authenticated = $authorized = false;

$route = $this->router->getCurrentRoute();

if (! $this->auth->check(false)) {
try {
$this->auth->authenticate(
$route->getAuthenticationProviders()
);
$authenticated = true;
} catch (UnauthorizedHttpException $e) {
} catch (BadRequestHttpException $e) {
}
}

if (! $authenticated) {
return api_response_i18n(401, 'UNAUTHENTICATED');
}

$user = \Auth::user();

if (true
&& (method_exists($user, 'hasPermission'))
&& ($user->hasPermission(
$request->getMethod(),
$request->route()->uri
))
) {
$permissions = [];
$_authorized = true;
$params = $request->route()->parameters();
parse_str(trim($permission), $permissions);

foreach ($permissions as $_permission => $objects) {
if ($_authorized && $_permission) {
$authorizer = 'authorize'.$_permission;

if ($_objects = explode(';', $objects)) {
foreach ($_objects as $object) {
if ($_object = ($params[$object] ?? null)) {
$object = strtolower(trim($object));
$authorizer .= 'When'.ucfirst($object);

$_authorized = $this->authorize(
$user, $authorizer, $_object
);

$this->setErrorO($_authorized, 41);
}
}
} else {
$_authorized = $this->authorize($user, $authorizer);

$this->setErrorO($_authorized, 42);
}
}
}

$authorized = $_authorized;
}

if (! $authorized) {
return api_response(403, i18n('UNAUTHORIZED: {_err}', [
'_err' => $this->_err
]));
}

return $next($request);
}

protected function setErrorO(bool $authorized, $code)
{
if (! $this->_err) {
$this->_err = $authorized ? '' : $code;
}
}

protected function authorize(
$user,
string $authorizer,
string $object = null
) : bool
{
if (! method_exists($user, $authorizer)) {
$this->setErrorO(false, 51);

return false;
}

return (bool) $user->{$authorizer}($object);
}
}