用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 报文响应和隐藏敏感信息。虽然最终的方法还不够简洁优雅,比如参数验证,后续可以使用一些第三方的参数校验扩展进行替换。