API接口设计

API接口设计

首先接口是不能裸奔的,不然你就BOOM了!!!
首先接口是不能裸奔的,不然你就BOOM了!!!
首先接口是不能裸奔的,不然你就BOOM了!!!

一、那么接口一般面临三个安全问题

  1. 请求身份是否合法
  2. 请求参数是否被篡改
  3. 请求是否唯一(重放攻击)

二、那么针对这三个问题,怎么解决呢??

  1. 请求身份合法问题就用接口签名认证(sign)解决,需要登录才能操作的api还要验证用户的token
  2. 请求参数篡改的问题就对入参除sign外的其他参数的key升序或者降序,再拼上api的加密密钥secretKey=,然后用一个不可逆的加密算法,例如md5,这样就能得出sign
  3. 请求的唯一问题就定义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拦截响应信息的枚举类

<?php

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的

<?php

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校验的拦截用中间件去做。

  1. 首先建一个api的配置文件(api.php),读.env里的配置,这里的params_check就是配置接口入参检测的,凡是配置的参数都是必传的,key是接口方法名(取决于路由,本人一般路由与接口方法名会保持一致)。这里不用表单验证器是因为本人觉得每个接口方法都要写一个表单验证实在繁琐,所以改成了这种配置的方式。
<?php

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
            ]
        ]
    ],
];
  1. api基类的实现(BaseApi)
<?php

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;
    }
}
  1. 用到的一些公共函数放到common.php中,这个看习惯
<?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";
    }
}
  1. Api服务类实现接口的签名认证和token校验方法
<?php

namespace App\Http\Contracts\Api\v1;
interface ApiInterface
{
    //api签名认证
    public function checkSign($params);

    //用户token校验
    public function checkToken($params);
}
<?php

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;
    }
}
  1. api认证拦截的中间件
<?php

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);
    }
}


四、下面以简单的登录注册为例子

  1. User模型类
<?php
/**
 * 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']
        ];//返回登录信息
    }

}
  1. User控制器
<?php
/**
 * 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);
    }
}
  1. 配置路由
<?php

//用户路由
Route::group([
    'prefix' => 'user',
    'namespace' => 'Api\v1',
    'middleware' => 'api.intercept'//api认证拦截中间件
], function ($router) {
    $router->post('_login', 'User@_login');
    $router->post('_register', 'User@_register');
});

到这里api的签名认证就已经设计开发好了!!!感谢观看!!!

本作品采用《CC 协议》,转载必须注明作者和本文链接
本帖由系统于 2个月前 自动加精
《L01 基础入门》
我们将带你从零开发一个项目并部署到线上,本课程教授 Web 开发中专业、实用的技能,如 Git 工作流、Laravel Mix 前端工作流等。
《L02 从零构建论坛系统》
以构建论坛项目 LaraBBS 为线索,展开对 Laravel 框架的全面学习。应用程序架构思路贴近 Laravel 框架的设计哲学。
讨论数量: 10

很完整了,感谢分享

2个月前 评论

网页 sign 如何操作

2个月前 评论

向大佬学习

2个月前 评论

我承认了,是被你头像吸引进来的

2周前 评论

非常感谢分享。

2周前 评论

Illuminate\Contracts\Container\BindingResolutionException: Target class [api.intercept] does not exist. in file E:\www\blogs\vendor\laravel\framework\src\Illuminate\Container\Container.php on line 805

这个报错 求大佬指点

我知道了 没有注册中间件

2周前 评论

用户每过了86400秒 是不是还需要再次login一下

2周前 评论

讨论应以学习和精进为目的。请勿发布不友善或者负能量的内容,与人为善,比聪明更重要!