API介面設計

PHP大佬發表於2021-09-18

首先介面是不能裸奔的,不然你就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 協議》,轉載必須註明作者和本文連結

相關文章