用PHP写一个用户注册接口
开始用 ThinkPHP5 框架写 Web 接口,以一个用户注册接口,实现对数据库表的插入和查询、参数校验、加盐加密、Json 报文响应、http status code 设置、隐藏敏感信息等。
# 修改配置
├─config(应用配置目录)
│ ├─app.php 应用配置
│ ├─cache.php 缓存配置
│ ├─cookie.php Cookie配置
│ ├─database.php 数据库配置
│ ├─log.php 日志配置
│ ├─session.php Session配置
│ ├─template.php 模板引擎配置
│ ├─trace.php Trace配置
│ └─ ... 更多配置文件
│
├─route(路由目录)
│ ├─route.php 路由定义文件
│ └─ ... 更多路由定义文件
│
├─application (应用目录)
│ └─module (模块目录)
│ └─config(模块配置目录)
│ ├─app.php 应用配置
│ ├─cache.php 缓存配置
│ ├─cookie.php Cookie配置
│ ├─database.php 数据库配置
│ ├─log.php 日志配置
│ ├─session.php Session配置
│ ├─template.php 模板引擎配置
│ ├─trace.php Trace配置
│ └─ ... 更多配置文件
│
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
# 修改应用配置
修改应用配置文件 config/app.php
// 应用调试模式
'app_debug' => true,
// 默认输出类型
'default_return_type' => 'json',
// 使用注解路由
'route_annotation' => true,
// 显示错误信息
'show_error_msg' => true,
2
3
4
5
6
7
8
# 修改数据库配置
修改数据库配置文件 config/database.php
// 服务器地址
'hostname' => '127.0.0.1',
// 数据库名
'database' => 'demo',
// 用户名
'username' => 'root',
// 密码
'password' => '123456',
// 端口
'hostport' => '3306',
2
3
4
5
6
7
8
9
10
# 创建数据库
先用 navicat 连接数据库服务,然后新建一个数据库,名称为 demo
,字符集选 utf-8
。
接着建一个用户表 user
,添加以下字段:
字段 | 类型(长度) | 描述 |
---|---|---|
id | int(11) | 用户唯一标识 |
username | varchar(16) | 用户名 |
password | char(60) | 密码 |
create_time | timestamp(0) | 创建时间 |
update_time | timestamp(0) | 更新时间 |
将 id
、username
、password
设为 Not Null
,也就是不允许字段值为空。
最后把 id
设为 Key
,也就是主键,并且设置 Auto increment
,也就是自动递增。
这样,一张简单的用户表就建好了。
# sql脚本
也可以使用 sql 脚本语句创建表:
CREATE TABLE `user` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`username` varchar(16) NOT NULL,
`password` char(60) NOT NULL,
`create_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
`update_time` timestamp NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
2
3
4
5
6
7
8
# 开始写代码
MVC 设计模式,M 指的是 model,也就是模型;V 指的是 view,也就是视图;C 是 controller,也就是控制器。
由于我们只开发 web 接口,不需要做前端页面,所以 view 层是可以去掉的。
# Model
在 application/index/
下新建一个目录 model
,然后在 model
目录下新建一个 User.php
。
<?php
namespace app\index\model;
use think\Model;
class User extends Model
{
}
2
3
4
5
6
7
8
这样我们的模型就定义好了。
# Controller
在 application/index/controller
下新建一个 User.php
,并将我们创建好的 User
Model 进行使用声明。由于 Controller 命名为 User
,Model 也命名为 User
,所以这里 Model 使用 as UserModel
进行区分。
<?php
namespace app\index\controller;
use think\Controller;
use app\index\model\User as UserModel;
class User extends Controller
{
}
2
3
4
5
6
7
8
9
10
我们新建的数据库还没有添加数据,那么我们先来实现一个用户注册接口。
# 用户注册接口 1.0
/**
* @param string $username 用户名
* @param string $password 密码
* @return mixed
* @route('user/register')
*/
public function register($username, $password)
{
$user = new UserModel([
'username' => $username,
'password' => $password
]);
$user->save();
return $user;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
因为我们前面配置的时候已经设置了使用注解路由 'route_annotation' => true
,所以原本要通过路由 /index/user/register
(模块名/控制器名/操作名),现在只需要 /user/register
即可。
注意,@route('user/register')
建议写在注释的最后一行,否则后面还需要一个空行。
调用接口,就可以获取到参数,并往数据库user
表里插入数据。
响应报文:
{
"username": "bingo",
"password": "123456",
"id": "1"
}
2
3
4
5
# 加盐加密
出于数据安全的角度考虑,类似用户密码的这种敏感数据,不能明文保存到数据库中,我们需要对用户提交的密码进行加密。
加密的做法有很多,各有各的利弊,不少开发者会直接进行 md5
加密,甚至是多层嵌套的 md5
加密,类似 md5(md5(password))
。
但我不推荐使用这种方式,为什么?因为只要明文是相同的,通过我们这种 md5
加密后得到的密文就是相同的,于是攻击者就可以通过撞库的方式来破解出明文,也就是使用所谓的 彩虹表
。
个人推荐使用加盐加密
,加盐就是向明文中加入随机数,然后再进行 md5
加密,这样即使明文是相同的,每次加密后的密文也都是不同的,这就可以加大暴力破解的难度。
那么我们要做的就是:
- 用户注册时,生成一个随机的字符串作为
盐值
,保存到用户表中的salt
字段。 - 用户注册提交的明文密码,使用
md5($password.$salt)
的方式加密得到密文,再将密文保存到password
字段。 - 用户登录时,先取出该用户的盐值
salt
,使用相同的加密方式,对用户提交的明文密码进行加密,再将结果与用户表中保存的password
进行对比,如果两个哈希值相等,则说明用户提交的密码正确;反之则说明密码提交错误。
我们接下来就使用这种加盐加密的方式,完善一下这个用户注册接口。
# 用户注册接口 2.0
首先,我们给 User
表中添加一个字段 salt
。
然后开始修改 register
方法。
public function register($username, $password)
{
$salt = md5(uniqid(microtime(true),true));
$password = md5($password . $salt);
$user = new UserModel([
'username' => $username,
'password' => $password,
'salt' => $salt
]);
$user->save();
return $user;
}
2
3
4
5
6
7
8
9
10
11
12
13
再次调用接口,响应报文:
{
"username": "bingo",
"password": "74a067e6926a40d9c8cc2c6dda019954",
"salt": "b859baaad7ab7fb99e82d530ab9239c5",
"id": "2"
}
2
3
4
5
6
可以看到 salt
和 password
都已经保存成功,现在已经实现密码加密存储了,但总感觉不够优雅!
# BCrypt 加密
使用加盐加密,按照上面的方案,需要手动生成盐、再加密;验证密码是否正确时,则要手动查询盐、加密后再比较字符串,略显麻烦,有没有更优雅的方式呢?答案肯定是有的。
BCrypt
算法将 salt
随机并混入最终加密后的密码,验证时也不需要提供之前的 salt
。
这样开发者就不需要手动生成盐值,更不需要存到数据库中,可以完全不关注 salt
。
// 加密用法 (cost可以不传,默认是10)
$hash = password_hash($value, PASSWORD_BCRYPT, ['cost' => 10]);
2
password_hash
后得到的 hash
将会是 60 个字符的字符串,其中包含了算法、cost
和盐值,可以直接使用 password_verify
进行验证。
// 验证用法
$flag = password_verify($password, $hash);
2
# 用户注册接口 3.0
把 User
表中刚添加的 salt
字段字段删除。
然后开始修改 register
方法:
public function register($username, $password)
{
$user = new UserModel([
'username' => $username,
'password' => password_hash($password, PASSWORD_BCRYPT)
]);
$user->save();
return $user;
}
2
3
4
5
6
7
8
9
10
再次调用接口,响应报文:
{
"username": "bingo",
"password": "$2y$10$9Odxs8sD3Kkv8puR0gzuJO78yNyyOUE6hfdQhlEaTp1FGPPxxRlMq",
"id": "3"
}
2
3
4
5
同样可以看到密码已经加密保存成功,这就很优雅。
# 拓展补充
之所以可以不保存盐值等信息,是因为这些数据都已经包含在了加密后的哈希值中。
以 $2y$10$9Odxs8sD3Kkv8puR0gzuJO78yNyyOUE6hfdQhlEaTp1FGPPxxRlMq
来举例:
- 三个
$
是分割符,可以将哈希值拆分为三个值。 - 第一个值
2y
是 bcrypt 加密版本号。 - 第二个值
10
是cost
的值。 - 第三个值
9Odxs8sD3Kkv8puR0gzuJO78yNyyOUE6hfdQhlEaTp1FGPPxxRlMq
中,前 22 位是salt
值,也就是9Odxs8sD3Kkv8puR0gzuJO
。 - 剩下的字符串就是密码的密文了,也就是
78yNyyOUE6hfdQhlEaTp1FGPPxxRlMq
。
所有需要的信息都包含在内,使得验证函数不需要储存额外盐值等信息即可验证哈希,判断密码是否和指定的散列值匹配。
# 接口响应报文隐藏密码
用户注册完成,并不需要知道、也不应该拿到经过服务端加密后的密码,所以我们的注册接口,响应的报文应该去掉 password
键值对。
在 TP 框架中,返回的模型想要隐藏某些字段,有两种方法,分为永久隐藏和临时隐藏。
# 永久隐藏
在 User
模型类中,定义一个 protected
的 hidden
数组,数组中填入需要隐藏的字段名,这些字段将不会返回给模型对象。
<?php
namespace app\index\model;
use think\Model;
class User extends Model
{
protected $hidden = [
'password'
];
}
2
3
4
5
6
7
8
9
10
11
12
再次调用接口,响应报文:
{
"username": "bingo",
"id": "4"
}
2
3
4
# 临时隐藏
如果我们只是在某些场景下才需要隐藏某些字段,但一般情况下都需要返回这些字段。
这样上面的方法就不适合了,不能永久隐藏,解决方案就是,在控制器中给返回的模型加上 ->hidden(['attr'])
。
例如,我们可以修改 register
方法:
public function register($username, $password)
{
$user = new UserModel([
'username' => $username,
'password' => password_hash($password, PASSWORD_BCRYPT)
]);
$user->save();
return $user->hidden(['password']);
}
2
3
4
5
6
7
8
9
10
再次调用接口,响应报文:
{
"username": "bingo",
"id": "5"
}
2
3
4
# 用户注册接口 4.0
我们上边的操作都是直接做数据插入,虽然 username
一样,但因为 username
不是 key
,id
才是 key
,所以数据都可以插入成功。
而实际的用户注册场景需要保证 username
的唯一性,也就是系统内没有其他用户使用该 username
,才能进行注册;如果 username
已经被使用了,则返回一个错误提示给用户。
那么继续完善 register
方法:
public function register($username, $password)
{
// 查询该username是否已存在
$user = UserModel::where('username', $username)->find();
// username 已存在
if ($user != null) {
return json(["message" => "用户名已存在"], 409);
}
// 插入数据库
$user = new UserModel([
'username' => $username,
'password' => password_hash($password, PASSWORD_BCRYPT)
]);
$user->save();
// 查询数据并返回
$user = UserModel::get($user->id);
return $user->hidden(['password']);
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
这样,当我们继续使用相同的参数调用接口时,响应报文:
{
"code": "409",
"message": "用户名已存在"
}
2
3
4
# 参数验证
永远不要相信用户提交的数据,一般情况下,我们都需要对参数进行校验。只有传参正确,我们才有必要继续往下执行业务逻辑,以及操作数据库。如果参数没传或者传错,我们应该直接响应异常。
例如:
username
和password
是必传的,不能为null
。username
有长度限制要求,最长 16 位。password
有长度限制要求,长度 6~16 位。
# 用户注册接口 5.0
/**
* @route('user/register')
*/
public function register()
{
$username = input('username');
$password = input('password');
if (empty($username)) {
return json(["message" => "用户名不能为空"], 400);
}
if (empty($password)) {
return json(["message" => "密码不能为空"], 400);
}
if (mb_strlen($username) > 16) {
return json(["message" => "用户名长度不能超过16位"], 400);
}
if (mb_strlen($password) < 6 || mb_strlen($password) > 16) {
return json(["message" => "密码长度应为6~16位"], 400);
}
// 查询该username是否已存在
$user = UserModel::where('username', $username)->find();
// username 已存在
if ($user != null) {
return json(["message" => "用户名已存在"], 409);
}
// 插入数据库
$user = new UserModel([
'username' => $username,
'password' => password_hash($password, PASSWORD_BCRYPT)
]);
$user->save();
// 查询数据并返回
$user = UserModel::get($user->id);
return $user->hidden(['password']);
}
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
# 字符串 null 的处理
现在已经加上了参数验证,如果调用接口的时候 username
和 password
没有传,或者传的参数长度不符合我们的要求,都会返回一个 400
的报错,看似已经很完美了,但,这就够了吗?当然不是!还有一个非常容易忽略的问题,经常可能会出现一些 bug
。
时刻要记住,PHP 是弱类型的语言,如果传参中有字符串的null
,也就是?username=null
时,在 PHP 中获取参数 username
拿到的值是 null
,下面这段代码就会出问题:
// 查询该username是否已存在
$user = UserModel::where('username', $username)->find();
2
因为 TP 框架封装的 sql
语句,内部实现大概是
select * from user where 'username' = $username
当 $username
为 null
时,实际 sql 语句也就是:
select * from user where username = null
问题来了,对于数据库来说,这条语句的意思是查询 user
表中 username
字段没有值的数据,而不是我们想要的 username
字段的值是 null
的数据,这样是查询不到任何数据的。
如果要实现我们的需求,sql 语句应该是:
select * from user where username = 'null'
这样才能实现查出表中 username
字段的值是字符串的 null
的数据。
所以,基于上面所述,当用户传递的参数中,username
是字符串的 null
时,使用框架的 UserModel::where('username', $username)->find();
是查询不到结果的,那么多次调用接口,就会重复插入多条值为 null
的用户。那么 username
的唯一性原则就被破坏了,当这些用户进行登录时,我们就无法判断应该对比哪条数据的密码,从而判断用户密码是否输入正确。
解决方案有好几种:
- 要么前端注册限制用户名不能输入
null
, - 要么后端限制用户名不能是
null
, - 再要么都允许设置
null
,但后端需要替换值为null
时的sql
查询语句,不能使用 TP 框架提供的查询方法。
其实最好的方式就是参数验证时,把 null
值也给判断了,不允许用户输入 null
。这样的话,后端可以统一使用 TP 框架提供的查询方法;
而且就算允许用户输入 null
,系统里也只能有一个用户的用户名是 null
,从程序设计的角度出发,完全没有必要为了一个特殊用户去大作周张。
# 用户注册接口 6.0
/**
* @route('user/register')
*/
public function register()
{
$username = input('username');
$password = input('password');
if (empty($username) || $username == 'null') {
return json(["message" => "用户名不能为空"], 400);
}
if (empty($password)) {
return json(["message" => "密码不能为空"], 400);
}
if (mb_strlen($username) > 16) {
return json(["message" => "用户名长度不能超过16位"], 400);
}
if (mb_strlen($password) < 6 || mb_strlen($password) > 16) {
return json(["message" => "密码长度应为6~16位"], 400);
}
// 查询该username是否已存在
$user = UserModel::where('username', $username)->find();
// username 已存在
if ($user != null) {
return json(["message" => "用户名已存在"], 409);
}
// 插入数据库
$user = new UserModel([
'username' => $username,
'password' => password_hash($password, PASSWORD_BCRYPT)
]);
$user->save();
// 查询数据并返回
$user = UserModel::get($user->id);
return $user->hidden(['password']);
}
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
# 总结
暂时写到这,我们以一个用户注册接口示例,做到了对数据库表的插入和查询、参数校验、加盐加密、http status code 设置、Json 报文响应和隐藏敏感信息。虽然最终的方法还不够简洁优雅,比如参数验证,后续可以使用一些第三方的参数校验扩展进行替换。