API接口设计
首先接口是不能裸奔的,不然你就BOOM了!!!
首先接口是不能裸奔的,不然你就BOOM了!!!
首先接口是不能裸奔的,不然你就BOOM了!!!
一、那么接口一般面临三个安全问题
- 请求身份是否合法
- 请求参数是否被篡改
- 请求是否唯一(重放攻击)
二、那么针对这三个问题,怎么解决呢??
- 请求身份合法问题就用接口签名认证(sign)解决,需要登录才能操作的api还要验证用户的token
- 请求参数篡改的问题就对入参除sign外的其他参数的key升序或者降序,再拼上api的加密密钥secretKey=,然后用一个不可逆的加密算法,例如md5,这样就能得出sign
- 请求的唯一问题就定义api必须传ts(时间戳)和nonce(随机唯一code)这两个参数,后端将nonce作为key用redis存起来,给一个过期时间,只要是在过期内重复请求就拦截
这样下来,三个问题就能解决了,这是常规的接口认证方式!!!
三、接下来就是CODING TIME
首先我这里图个方便,api响应用了组件
composer require sevming/laravel-response:^1.0
涉及到接口拦截响应msg,code还有用到得缓存key这些建议都用枚举(enum)存放,还有api一般都有v1、v2…等不同版本,所以要做好目录结构。
这是存放api拦截响应信息的枚举类
namespace App\Http\Enums\Api\v1; | |
class ApiResponseEnum | |
{ | |
const DEFECT_SIGN = '缺失sign签名|10001'; | |
const DEFECT_TIMESTAMP = '缺失ts时间戳|10002'; | |
const DEFECT_NONCE = '缺失nonce|10003'; | |
const INVALID_SIGN = '非法sign签名|20001'; | |
const INVALID_TIMESTAMP = '非法ts时间戳|20002'; | |
const INVALID_NONCE = '非法请求|20003'; | |
const DEFECT_TOKEN = '缺失token|30001'; | |
const INVALID_TOKEN = '非法token|30002'; | |
const TWICE_PASSWORD_NOT_SAME = '两次密码不一致|40001'; | |
const ACCOUNT_HAS_REGISTER = '账号已注册|40002'; | |
const INVALID_EMAIL_FORMAT = '邮箱格式不对|40003'; | |
const INVALID_PASSWORD_LENGTH = '密码至少8位|40004'; | |
const WEI_CODE_HAS_REGISTER = '微聊号已注册|40005'; | |
const REGISTER_ERROR = '注册失败|40006'; | |
const ACCOUNT_NOT_EXISTS = '账号不存在|40007'; | |
const ACCOUNT_HAS_BAN = '账号已被封禁|40008'; | |
const INVALID_PASSWORD = '密码错误|40009'; | |
} |
还有一个存放缓存key的
namespace App\Http\Enums\Api\v1; | |
//api 缓存KEY 枚举类 | |
class ApiCacheKeyEnum | |
{ | |
const NONCE_CACHE_KEY = 'api_request_nonce:'; | |
const TOKEN_CACHE_KEY = 'user_token:'; | |
} |
关于api认证的设计
设计思想:首先在api的基类中统一对接口入参做一个入参检测,也就是配置必传参数、设置默认值等,这样就不用在业务层中对参数做繁琐的判空处理。然后api认证及token校验的拦截用中间件去做。
- 首先建一个api的配置文件(api.php),读.env里的配置,这里的
params_check
就是配置接口入参检测的,凡是配置的参数都是必传的,key是接口方法名(取决于路由,本人一般路由与接口方法名会保持一致)。这里不用表单验证器是因为本人觉得每个接口方法都要写一个表单验证实在繁琐,所以改成了这种配置的方式。
use App\Http\Controllers\Api\BaseApi; | |
return [ | |
'v1' => [ | |
'api_key' => env('API_KEY_V1'),//api sign加密密钥 | |
'user_key' => env('USER_KEY_V1'),//用户token加密密钥, | |
//接口入参检测 | |
'params_check' => [ | |
'_register' => [ | |
'name' => [ | |
'type' => BaseApi::PARAM_STRING,//入参类型 | |
'default' => 'user' . uniqid()//默认值 | |
], | |
'email' => BaseApi::PARAM_STRING, | |
'password' => BaseApi::PARAM_STRING, | |
'confirm_password' => BaseApi::PARAM_STRING | |
], | |
'_login' => [ | |
'email' => BaseApi::PARAM_STRING, | |
'password' => BaseApi::PARAM_STRING | |
] | |
] | |
], | |
]; |
- api基类的实现(BaseApi)
namespace App\Http\Controllers\Api; | |
use App\Http\Enums\Api\v1\ApiCacheKeyEnum; | |
use Sevming\LaravelResponse\Support\Facades\Response; | |
use Illuminate\Support\Facades\Redis; | |
class BaseApi | |
{ | |
const PARAM_INT = 1;//整型 | |
const PARAM_STRING = 2;//字符串 | |
const PARAM_ARRAY = 3;//数组 | |
const PARAM_FILE = 4;//文件 | |
protected $params; | |
public function __construct() | |
{ | |
//入参检测,并初始化入参 | |
$this->params = $this->check_params(); | |
} | |
//api接口统一入参检测 | |
public function check_params() | |
{ | |
$action_list = explode('/', \request()->path()); | |
$params_check_key = end($action_list); | |
//入参检测配置 | |
$params_check = config('api.v1.params_check.' . $params_check_key); | |
//入参 | |
$params = request()->input(); | |
if (is_array($params_check) && $params_check) { | |
$flag = true; | |
foreach ($params_check as $key => $check) { | |
if (is_array($check)) { | |
$type = $check['type'] ?? 2;//默认是字符串 | |
$default = $check['default'] ?? '';//默认值 | |
} else { | |
$type = $check; | |
} | |
if (array_key_exists($key, $params)) { | |
switch ($type) { | |
case self::PARAM_INT: | |
$flag = is_numeric($params[$key]) || (isset($default) && empty($params[$key])); | |
break; | |
case self::PARAM_STRING: | |
$flag = is_string($params[$key]) || (isset($default) && empty($params[$key])); | |
break; | |
case self::PARAM_ARRAY: | |
$flag = is_array($params[$key]) || (isset($default) && empty($params[$key])); | |
break; | |
case self::PARAM_FILE: | |
$flag = $_FILES[$key] && isset($_FILES[$key]['error']) && $_FILES[$key]['error'] == 0; | |
break; | |
} | |
} else { | |
$flag = false; | |
} | |
if (!$flag) { | |
return Response::fail('invalid param ' . $key); | |
} | |
//默认值处理 | |
if (empty($params[$key]) && isset($default)) { | |
$params[$key] = $default; | |
} | |
//文件处理 | |
if ($type === BaseApi::PARAM_FILE) { | |
$params[$key] = $_FILES[$key]; | |
} | |
unset($default); | |
} | |
} | |
//根据token获取uid | |
if (array_key_exists('token', $params)) { | |
//获取uid | |
$redis = Redis::connection(); | |
$uid = $redis->get(ApiCacheKeyEnum::TOKEN_CACHE_KEY . $params['token']); | |
$params['uid'] = $uid ?? 0; | |
unset($params['token']); | |
} | |
unset($params['sign']); | |
return $params; | |
} | |
} |
- 用到的一些公共函数放到common.php中,这个看习惯
//公共函数 | |
if (!function_exists('make_sign')) { | |
//生成签名 | |
function make_sign($params) | |
{ | |
unset($params['sign']); | |
$params['api_key'] = config('api.v1.api_key');//拼接api加密密钥 | |
ksort($params);//key升序 | |
$string_temp = http_build_query($params); | |
return md5($string_temp); | |
} | |
} | |
if (!function_exists('encrypt_token')) { | |
//生成token | |
function encrypt_token($uid) | |
{ | |
$user_info = [ | |
'uid' => $uid, | |
'ts' => time() | |
]; | |
$user_key = config('api.v1.user_key'); | |
return openssl_encrypt(base64_encode(json_encode($user_info)), 'DES-ECB', $user_key, 0); | |
} | |
} | |
if (!function_exists('make_avatar')) { | |
function make_avatar($email) | |
{ | |
$md5_email = md5($email); | |
return "https://api.multiavatar.com/{$md5_email}.png"; | |
} | |
} |
- Api服务类实现接口的签名认证和token校验方法
namespace App\Http\Contracts\Api\v1; | |
interface ApiInterface | |
{ | |
//api签名认证 | |
public function checkSign($params); | |
//用户token校验 | |
public function checkToken($params); | |
} | |
namespace App\Http\Services\Api\v1; | |
use App\Http\Contracts\Api\v1\ApiInterface; | |
use App\Http\Enums\Api\v1\ApiCacheKeyEnum; | |
use App\Http\Enums\Api\v1\ApiResponseEnum; | |
use Illuminate\Support\Facades\Redis; | |
use Sevming\LaravelResponse\Support\Facades\Response; | |
class ApiService implements ApiInterface | |
{ | |
public static $instance = null; | |
/** | |
* @return static|null | |
* 单例模式 | |
*/ | |
public static function getInstance() | |
{ | |
if (is_null(self::$instance)) { | |
self::$instance = new static(); | |
} | |
return self::$instance; | |
} | |
/** | |
* @param $params array 入参 | |
* 签名认证 | |
*/ | |
public function checkSign($params) | |
{ | |
// TODO: Implement checkSign() method. | |
if (!isset($params['sign'])) { | |
return Response::fail(ApiResponseEnum::DEFECT_SIGN); | |
} | |
if (!isset($params['ts'])) { | |
return Response::fail(ApiResponseEnum::DEFECT_TIMESTAMP); | |
} | |
if (!isset($params['nonce'])) { | |
return Response::fail(ApiResponseEnum::DEFECT_NONCE); | |
} | |
$ts = $params['ts'];//时间戳 | |
$nonce = $params['nonce']; | |
$sign = $params['sign']; | |
$time = time(); | |
if ($ts > $time) { | |
return Response::fail(ApiResponseEnum::INVALID_TIMESTAMP); | |
} | |
$redis = Redis::connection(); | |
if ($redis->exists(ApiCacheKeyEnum::NONCE_CACHE_KEY . $nonce)) { | |
return Response::fail(ApiResponseEnum::INVALID_NONCE); | |
} | |
$api_sign = make_sign($params); | |
if ($api_sign !== $sign) { | |
return Response::fail(ApiResponseEnum::INVALID_SIGN); | |
} | |
//5分钟内一个sign不能重复请求,防止重放攻击 | |
$redis->setex(ApiCacheKeyEnum::NONCE_CACHE_KEY . $nonce, 300, $time); | |
return true; | |
} | |
/** | |
* @param $params | |
* TOKEN校验 | |
*/ | |
public function checkToken($params) | |
{ | |
// TODO: Implement checkToken() method. | |
$action_list = explode('/', \request()->path()); | |
$action = end($action_list); | |
//带下划线的方法无需登录,直接放行 | |
if (stripos($action, '_')) { | |
return true; | |
} | |
if (!isset($params['token'])) { | |
return Response::fail(ApiResponseEnum::DEFECT_TOKEN); | |
} | |
$token = $params['token']; | |
//查缓存是否存在该登录用户token | |
$redis = Redis::connection(); | |
$cache_token = $redis->get(ApiCacheKeyEnum::TOKEN_CACHE_KEY . $token); | |
if (!$cache_token) { | |
return Response::fail(ApiResponseEnum::INVALID_TOKEN); | |
} | |
return true; | |
} | |
} |
- api认证拦截的中间件
namespace App\Http\Middleware; | |
use App\Http\Services\Api\v1\ApiService; | |
use Closure; | |
class ApiIntercept | |
{ | |
public function handle($request, Closure $next) | |
{ | |
$params = $request->input(); | |
$env = config('env'); | |
if ($env !== 'local') { | |
//非本地环境,需要签名认证 | |
ApiService::getInstance()->checkSign($params); | |
} | |
//token检验 | |
ApiService::getInstance()->checkToken($params); | |
return $next($request); | |
} | |
} |
四、下面以简单的登录注册为例子
- User模型类
/** | |
* User: yanjianfei | |
* Date: 2021/9/18 | |
* Time: 10:17 | |
*/ | |
namespace App\Model; | |
use App\Http\Enums\Api\v1\ApiCacheKeyEnum; | |
use App\Http\Enums\Api\v1\ApiResponseEnum; | |
use Illuminate\Support\Facades\Redis; | |
use Sevming\LaravelResponse\Support\Facades\Response; | |
class User extends BaseModel | |
{ | |
//注册 | |
public function checkRegister($params) | |
{ | |
if ($params['password'] !== $params['confirm_password']) { | |
return Response::fail(ApiResponseEnum::TWICE_PASSWORD_NOT_SAME); | |
} | |
if (strlen($params['password']) < 8) { | |
return Response::fail(ApiResponseEnum::INVALID_PASSWORD_LENGTH); | |
} | |
$pattern = '^\w+([-+.]\w+)*@\w+([-.]\w+)*\.\w+([-.]\w+)*$'; | |
if (preg_match($pattern, $params['email'])) { | |
return Response::fail(ApiResponseEnum::INVALID_EMAIL_FORMAT); | |
} | |
$account_exits = self::query()->where('email', $params['email'])->exists(); | |
if ($account_exits) { | |
return Response::fail(ApiResponseEnum::ACCOUNT_HAS_REGISTER); | |
} | |
$wei_code_exists = self::query()->where('wei_code', $params['wei_code'])->exists(); | |
if ($wei_code_exists) { | |
return Response::fail(ApiResponseEnum::WEI_CODE_HAS_REGISTER); | |
} | |
$data = [ | |
'name' => $params['name'], | |
'password' => md5($params['password']), | |
'avatar' => make_avatar($params['email']), | |
'email' => $params['email'] | |
]; | |
$user = self::query()->create($data); | |
if (!$user) { | |
return Response::fail(); | |
} | |
//注册完后自动登录 | |
return $this->checkLogin($user, true); | |
} | |
/** | |
* @param $params | |
* @param false $auto 自动登录 | |
*/ | |
public function checkLogin($params, $auto = false) | |
{ | |
$user = $params; | |
if (!$auto) { | |
$user = self::query()->where('email', $params['email'])->first(); | |
if (!$user) { | |
return Response::fail(ApiResponseEnum::ACCOUNT_NOT_EXISTS); | |
} | |
if ($user['status'] == 0) { | |
return Response::fail(ApiResponseEnum::ACCOUNT_HAS_BAN); | |
} | |
if ($user['password'] !== md5($params['password'])) { | |
return Response::fail(ApiResponseEnum::INVALID_PASSWORD); | |
} | |
} | |
$token = encrypt_token($user['id']);//生成token | |
$redis = Redis::connection(); | |
$redis->setex(ApiCacheKeyEnum::TOKEN_CACHE_KEY . $token, 86400, $user['id']);//reids存放token | |
return [ | |
'token' => $token, | |
'name' => $user['name'], | |
'avatar' => $user['avatar'] | |
];//返回登录信息 | |
} | |
} |
- User控制器
/** | |
* User: yanjianfei | |
* Date: 2021/9/17 | |
* Time: 17:01 | |
*/ | |
namespace App\Http\Controllers\Api\v1; | |
use App\Http\Controllers\Api\BaseApi; | |
use Sevming\LaravelResponse\Support\Facades\Response; | |
use App\Model\User as UserModel; | |
class User extends BaseApi | |
{ | |
public function _login(UserModel $user) | |
{ | |
$data = $user->checkLogin($this->params); | |
return Response::success($data); | |
} | |
public function _register(UserModel $user) | |
{ | |
$data = $user->checkRegister($this->params); | |
return Response::success($data); | |
} | |
} |
- 配置路由
//用户路由 | |
Route::group([ | |
'prefix' => 'user', | |
'namespace' => 'Api\v1', | |
'middleware' => 'api.intercept'//api认证拦截中间件 | |
], function ($router) { | |
$router->post('_login', 'User@_login'); | |
$router->post('_register', 'User@_register'); | |
}); |
到这里api的签名认证就已经设计开发好了!!!感谢观看!!!