Laravel 多端简单鉴权

Laravel 多端简单鉴权

基于 Laravel + Redis 简单的实现了多端登陆、注册、修改密码、鉴权功能。

多端登陆指的是在多个客户端(比如:网页、小程序、APP)进行登陆然后不影响到其他端的一个已登陆状态。

用户登陆后会为当前客户端分配一个令牌,并把之前这个客户端的令牌(存在的情况下)设置为已过期。用户修改密码会把所有的客户端可以使用的令牌设置为已过期,这样子就需要所以客户端进行重新登陆了。

数据返回

请求都会返回 code,1 为成功 0 为失败。

成功不一定会返回 data,失败一定会返回 message

另外:HTTP 状态码 401 说明用户没有鉴权通过需要重新登陆。

辅助函数

app\helpers.php

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
<?php

if(!function_exists('result')) {
/**
* 返回数据
*
* @param string|array $data 数据
* @param int $status 状态码
* @return \Illuminate\Http\Response
*/
function result($data = null, int $status = 200) {
return response()->json(
is_null($data) ? ['code' => 1] : ['code' => 1, 'data' => $data],
$status
);
}
}

if(!function_exists('error')) {
/**
* 返回错误
*
* @param string $msg 错误信息
* @param int $status 状态码
* @return \Illuminate\Http\Response
*/
function error($msg = '参数错误', int $status = 200) {
return response()->json(
['code' => 0, 'message' => $msg],
$status
);
}
}

if(!function_exists('error_system')) {
/**
* 返回系统错误
*
* @param int $number 编号
* @return \Illuminate\Http\Response
*/
function error_system($number = null) {
return error(is_null($number) ? '系统错误' : '系统错误[' . $number . ']');
}
}

if(!function_exists('get_client_name')) {
/**
* 获取客户端名称
*
* @param string $default 默认
* @return string
*/
function get_client_name($default = 'wechat-browser') {
$name = request()->header('client-name');

if(is_null($name)) {
return $default;
}

if(in_array($name, config('config.client_list'))) {
return $name;
}

return $default;
}
}

if(!function_exists('get_token_prefix')) {
/**
* 获取令牌存储前缀
*
* @param string|null $client_name 客户端名称
* @return string
*/
function get_token_prefix($client_name = null) {
if(is_null($client_name)) {
$client_name = get_client_name();
}

return sprintf('token:%s:', $client_name);
}
}

配置文件

config\config.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<?php

return [
// token 字符串长度
'token_length' => 32,

// 客户端列表
'client_list' => [
'wechat-browser',
],

// token 有效时长,单位分钟
'token_expire' => 60,

// 禁止时长,单位分钟
'prohibit_expire' => 60,

// 禁止次数
'prohibit_number' => 5,
];

CSRF

关闭 CSRF 验证。

app\Http\Middleware\VerifyCsrfToken.php

1
2
3
protected $except = [
'*',
];

模型

用户模型,方便我们的一个使用。

app\Models\Users.php

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
<?php

namespace App\Models;

use Illuminate\Support\Facades\Hash;
use Illuminate\Database\Eloquent\Model;

class Users extends Model
{
protected $table = 'users';

protected $fillable = [
'username',
'password',
];

/**
* 对输入的密码进行加密。
*
* @param string $value
* @return void
*/
public function setPasswordAttribute($value)
{
$this->attributes['password'] = Hash::make($value);
}
}

控制器

app\Http\Controllers\AuthController.php

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
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
<?php

namespace App\Http\Controllers;

use App\Models\Users;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Redis;
use Illuminate\Support\Facades\Validator;

class AuthController extends Controller
{
/**
* 获取用户输入
*
* @return array
*/
protected function get_input()
{
return request()->only('username', 'password');
}

/**
* 验证用户输入
*
* @param array $input 用户输入内容
* @return string|bool
*/
protected function validator_input(array $input)
{
$validator = Validator::make($input, [
'username' => 'required|min:8|max:32',
'password' => 'required|min:8|max:32',
]);

$validator->setAttributeNames([
'username' => '用户名',
'password' => '密码',
]);

return $validator->fails()
? (string) $validator->errors()->first()
: true;
}

/**
* 生成 token 字符串
*
* @return string
*/
protected function generate_token_str()
{
return Str::random(config('config.token_length'));
}

/**
* 生成 token
*
* @param int $uid 用户ID
* @return string
*/
protected function generate_token(int $uid)
{
$token = $this->generate_token_str();

$client_name = get_client_name();

$token_data = [
// 用户 ID
'uid' => $uid,
// 客户端名称
'client' => $client_name,
// 过期时间
'invalid_at' => (string) now()->addMinutes(config('config.token_expire')),
// 创建时间
'create_at' => (string) now(),
];

$token_prefix = get_token_prefix($client_name);

Redis::set($token_prefix . $token, serialize($token_data));

// 获取上一次 token 的 key
$last_token = Redis::lpop($token_prefix . $uid);

// 不存在说明第一次登陆或者清除了。
if(is_null($last_token)) {
Redis::rpush($token_prefix . $uid, $token);

return $token;
}

// 对于 Redis 其实还不是说很熟悉,对于提取列表最后一个目前我的方案是
// 先用 lpop 然后在重新追加回去;先获取列表数量然后在通过 index 会比较麻烦
Redis::rpush($token_prefix . $uid, $last_token, $token);

$this->set_invalid_token($token_prefix . $last_token);

return $token;
}

/**
* 设置失效 token
*
* @param string $token_name token名称
* @return bool
*/
protected function set_invalid_token(string $token_name)
{
$data = Redis::get($token_name);

// token 名称有误,获取不到数据
if(is_null($data)) {
return false;
}

$data = unserialize($data);

// 已过了有效期
if(strtotime($data['invalid_at']) < time()) {
return false;
}

// 设置当前时间为最后的有效期然后进行保存
$data['invalid_at'] = (string) now();

Redis::set($token_name, serialize($data));

return true;
}

/**
* 是否可以正常登陆
*
* @param string $data
* @return bool
*/
protected function has_login_error($data)
{
if(is_null($data)) {
return true;
}

$data = unserialize($data);

if(is_null($data['prohibit_at'])) {
return true;
}

// 是否超过禁止结束时间
return strtotime($data['prohibit_at']) < time();
}

/**
* 设置登陆错误记录
*
* @param string $data
* @return array
*/
protected function set_login_error($data)
{
// 没有记录返回一个默认的
if(is_null($data)) {
return [
// 错误次数
'value' => 1,
// 禁止结束时间
'prohibit_at' => null,
// 上一次失败时间
'last_at' => (string) now(),
];
}

$data = unserialize($data);

// 自增错误次数
$data['value']++;

// 获取禁止时长
$prohibit_expire = config('config.prohibit_expire');

// 当前是否不满足禁止条件
if(strtotime($data['last_at']) < time() - $prohibit_expire * 60
&& $data['value'] < config('config.prohibit_number')
) {
// 清除禁止结束时间
$data['prohibit_at'] = null;
} else {
// 清除错误次数
$data['value'] = 0;
// 设置禁止结束时间
$data['prohibit_at'] = (string) now()->addMinutes($prohibit_expire);
}

$data['last_at'] = (string) now();

return $data;
}

/**
* 登陆
*
* @return \Illuminate\Http\Response
*/
public function login()
{
// 获取用户输入
$input = $this->get_input();

// 验证用户输入
if(($error = $this->validator_input($input)) !== true) {
return error($error);
}

// 登陆错误日志存储 key
$login_error_key = 'login_error:' . $input['username'];

// 获取登陆错误日志
$login_error_data = Redis::get($login_error_key);

// 用户账号因为登陆密码失败错误太多被禁止登陆了。
if($this->has_login_error($login_error_data) === false) {
return error('暂时无法登陆');
}

// 获取用户数据
$user = Users::select('id', 'password', 'status')
->where('username', $input['username'])
->first();

// 查不到该用户
if(is_null($user)) {
return error('用户不存在');
}

// 用户可能是被拉黑了之类。
if($user->status !== 1) {
return error('用户禁止登陆');
}

// 密码错误需要防止用户频繁重试。
if(!Hash::check($input['password'], $user->password)) {
$login_error_data = $this->set_login_error($login_error_data);

Redis::set($login_error_key, serialize($login_error_data));

if(is_null($login_error_data['prohibit_at'])) {
return error('密码错误');
}

// 用户账号因为登陆密码失败错误太多被禁止登陆了。
return error('暂时无法登陆');
}

$token = $this->generate_token($user->id);

return result([
'token' => $token,
]);
}

/**
* 注册
*
* @return \Illuminate\Http\Response
*/
public function register()
{
// 获取用户输入
$input = $this->get_input();

// 验证用户输入
if(($error = $this->validator_input($input)) !== true) {
return error($error);
}

// 查询当前账号是否已存在
$user = Users::where('username', $input['username'])
->exists();

if($user) {
return error('账号已存在');
}

// 用户注册初始数据
$data = [
'username' => $input['username'],
'password' => $input['password'],
];

$result = Users::create($data);

return $result ? result() : error('注册失败');
}

/**
* 重置密码
*
* @param \Illuminate\Http\Request $request
* @return \Illuminate\Http\Response
*/
public function reset(Request $request)
{
// 获取用户ID
$uid = $request->uid;

// 获取用户输入
$input = request()->only('password_old', 'password_new');

// 验证用户输入
$validator = Validator::make($input, [
'password_old' => 'required|min:8|max:32',
'password_new' => 'required|min:8|max:32',
]);

// 验证字段定义名称
$validator->setAttributeNames([
'password_old' => '旧密码',
'password_new' => '新密码',
]);

if($validator->fails()) {
return error((string) $validator->errors()->first());
}

if($input['password_old'] === $input['password_new']) {
return error('新旧密码一致');
}

$user = Users::select('password')
->where('id', $uid)
->first();

// 验证旧密码
if(!Hash::check($input['password_old'], $user->password)) {
return error('旧密码错误');
}

$user->password = $input['password_new'];

// 用户重置密码需要把所有端的最后一个 token 都设置过期,让用户重新进行登陆
// 这里返回的状态码还是 200,需要前端配合跳转到登录页
$client_list = config('config.client_list');

foreach($client_list as $client_name) {
// 不清除当前登录端的 token
// if($client_name === get_client_name()) {
// continue;
// }

$token_prefix = get_token_prefix($client_name);

$last_token = Redis::lpop($token_prefix . $uid);

if(is_null($last_token)) {
continue;
}

$this->set_invalid_token($token_prefix . $last_token);
}

return $user->save()
? result()
: error_system();
}
}

中间件

鉴权中间件

app\Http\Middleware\AuthMiddleware.php

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
<?php

namespace App\Http\Middleware;

use Illuminate\Support\Facades\Redis;
use Closure;

class AuthMiddleware
{
/**
* Handle an incoming request.
*
* @param \Illuminate\Http\Request $request
* @param \Closure $next
* @return mixed
*/
public function handle($request, Closure $next)
{
// 获取令牌
$token = $request->header('Authorization');

// 令牌长度和配置文件规定的不一致
if(strlen($token) !== config('config.token_length')) {
return error('鉴权异常', 401);
}

$token_data = Redis::get(get_token_prefix() . $token);

// token 名称有误,获取不到数据
if(is_null($token_data)) {
return error('鉴权失败', 401);
}

$token_data = unserialize($token_data);

// 已过了有效期
if(strtotime($token_data['invalid_at']) < time()) {
return error('登陆已过期', 401);
}

$request->uid = $token_data['uid'];

return $next($request);
}
}

中间件注册

app\Http\Kernel.php

1
2
3
4
protected $routeMiddleware = [
...
'api_auth' => \App\Http\Middleware\AuthMiddleware::class,
];

路由

routes\web.php

1
2
3
4
5
6
7
8
9
10
<?php

// 登陆
Route::post('/login', 'AuthController@login');
// 注册
Route::post('/register', 'AuthController@register');
Route::middleware('api_auth')->group(function () {
// 重置密码
Route::post('/reset', 'AuthController@reset');
});
往上