API用户体系设计和身份认证实践

数据表

表 user

id(主键) username(唯一) password(唯一) salt token(唯一) token_expire_time token_invalid_time
1 username1 password1 OAu8d4 token1 1558021467 1,558,028,667
2 username2 password2 85Dfg2 token2 1558021468 1,558,028,668

表 nonce
user_id + nonce 唯一

id(主键) user_id nonce nonce_expire_time
1 1 nonce1 1558021467
2 2 nonce2 1558021468

前端公共代码

//对象按键名排序
function objectSortByKey(object)
{
    let keys = Object.keys(object);
    let len = keys.length;
    let i, k;
    let new_object = {};
    keys.sort();
    for (i = 0; i < len; i++) {
        k = keys[i];
        new_object[k] = object[k];
    }
    return new_object;
}

//生成指定长度的随机字符串
function randStr(length)
{
    //细节请自行实现
}

//获取当前时间戳
function time()
{
    return Math.floor(microtime()/100); 
}

//获取当前毫秒时间戳
function microtime()
{
    return (new Date()).getTime();
}

后端公共代码

<?php
    //生成指定长度的随机字符串    
    function rand_str(length)
    {
        //细节请自行实现
    }
    
    /**
     * 密码对称加密函数
     * @param  string  $password
     * @param  string  $salt     
     * @param  string  $type //EN 加密、DE解密
     * @return string
     */
    function password_crypt($password, $salt, $type)
    {
        //细节请自行实现,算法请勿外泄
    }

获得账号

a. 服务商自己生成账号密码提供给请求方

b. 用户自行注册账号

客户端JS代码

import md5 from 'js-md5';

let username = 'test';   //用户输入的用户名
let password = '123456'; //用户输入的密码
password = md5(username + md5(password));

let post_data = {username, password};
//发送数据 post_data 至账号注册接口

后端接口PHP代码

<?php
    function password_encrypt($password)
    {
        $salt = rand_str(6);
        $password = password_crypt($password, $salt, 'EN');
        if(Db::table('user')->where('password', $password)->count() > 0){
            return password_encrypt($password);
        }
        return compact('password', 'salt');
    }

    $username = $_POST['username'];
    $password = $_POST['password'];
    $time = time();
    $token = md5(md5($username) . $time . rand_str(6);//初始化token
    $token_expire_time = 0; //设置为立即过期,初始化token不需被使用
    $token_invalid_time = 0;
    if(Db::table('user')->where('username', $username)->count() > 0){
        echo '用户名已存在';
        exit;
    }
    
    $encrypt_res = password_encrypt($password); 
    extract($encrypt_res);
        
    $user_data = compact(
        'username', 
        'password', 
        'salt',
        'token',
        'token_expire_time',
        'token_invalid_time'
    );
    //数据 $user_data 写入user表

获取token信息

客户端JS代码

import md5 from 'js-md5';

let username = 'test';   //用户输入的用户名
let password = '123456'; //用户输入的密码
let nonce = md5(microtime() + randStr(6)); 
let timestamp = time() ; 
let sign_key = md5(username + md5(password));

let post_data = {
    "username" : username,
    "nonce" : nonce,
    "timestamp" : timestamp
};

post_data['sign'] = md5(JSON.stringify(objectSortByKey(post_data)) + sign_key);
//发送数据 post_data 至获取token接口

后端接口PHP代码

<?php
    $post_data = $_POST;
    $req_timeout = 60; //设置请求超时时间
    $token_timeout = 7200; //设置token失效时间
    $token_invalid_timeout = 14400; //设置token作废时间
    // token作废后无法刷新token;
    $time = time();
    if($post_data['timestamp'] - $time > $req_timeout){
        echo '请求超时';
        exit;
    }
    
    $user_info = Db::table('user')->where('username', $post_data['username'])->find();
    if(empty($user_info){
        echo '账号或密码错误';
        exit;
    }
    
    $replay = Db::table('nonce')
    ->where('user_id', $user_info['id'])
    ->where('nonce', $post_data['nonce'])
    ->count();
    if($replay > 0){
        echo '请勿重复请求';
        exit;
    }
    
    $password = password_crypt($user_info['password'], $user_info['salt'], 'DE');
    
    $post_sign = $post_data['sign'];
    unset($post_data['sign']);
    ksort($post_data);
    $sign = md5(json_encode($post_data).$password);
    if($post_sign !== $sign){
        echo '非法请求';
        exit;
    }
    
    Db::table('nonce')->insert([
        'user_id' => $user_info['id'],
        'nonce' => $post_data['nonce'],
        'nonce_expire_time' => $time + $req_timeout*2
    ]);
    
    $token =md5(md5($post_data['username']) . $time . rand_str(6));
    $token_expire_time = $token_timeout  + $time;
    $token_invalid_time = $token_invalid_timeout  + $time;
    
    $token_data = compact('token', 'token_expire_time', 'token_invalid_time');
    Db::table('user')->where('id', $user_info['id'])->update($token_data);
    
    //输出 $token_data 数据至客户端

接口身份认证

usernamepassword 通过获取token接口 获取到 token和 token_expire_time token_invalid_time
username token token_expire_time token_invalid_time 进行本地持久化保存,可以是cookie或 LocalStorage 的方式

客户端JS代码

import md5 from 'js-md5';

let user_info = localStorage.getItem('user_info');
user_info = JSON.parse(user_info);

if(time() > user_info.token_invalid_time){
    alert('登陆超时');
    return ;
}
if(time() > user_info.token_expire_time){
    //走刷新token流程
    user_info = localStorage.getItem('user_info');
    user_info = JSON.parse(user_info);
}

let nonce = md5(microtime() + randStr(6)); 
let timestamp = time() ; 

let post_data = {
    "username" : user_info.username,
    "nonce" : nonce,
    "timestamp" : timestamp
    //更多请求请求参数根据实际业务添加
};

post_data['sign'] = md5(JSON.stringify(objectSortByKey(post_data)) + user_info.token);
//发送数据 post_data 至业务接口

后端接口PHP代码

<?php
    $post_data = $_POST;
    $req_timeout = 60; //设置请求超时时间
    $time = time();
    if($post_data['timestamp'] - $time > $req_timeout){
        echo '请求超时';
        exit;
    }

    $user_info = Db::table('user')->where('username', $post_data['username'])->find();
    if(empty($user_info){
        echo '账号不存在';
        exit;
    } 

    $replay = Db::table('nonce')
    ->where('user_id', $user_info['id'])
    ->where('nonce', $post_data['nonce'])
    ->count();
    if($replay > 0){
        echo '请勿重复请求';
        exit;
    }
    
    if($time > $user_info['token_invalid_time']){
        echo 'token无效';
        exit;
    }   
    
    //判断当前请求的接口是否为刷新token接口,开发时根据实际进行调整
    if(strtolower($_SERVER['REQUEST_URI'] ) !== strtolower('/api/freshToken')){
        if($time > $user_info['token_expire_time']){
            echo 'token过期';
            exit;
        }   
    }
    
    $post_sign = $post_data['sign'];
    unset($post_data['sign']);
    ksort($post_data);
    $sign = md5(json_encode($post_data).$user_info['token']);
    if($post_sign !== $sign){
        echo '非法请求';
        exit;
    }

    Db::table('nonce')->insert([
        'user_id' => $user_info['id'],
        'nonce' => $post_data['nonce'],
        'nonce_expire_time' => $time + $req_timeout*2
    ]);    
    
    //身份认证通过,继续处理实际业务逻辑

注意事项

  • 后端示例代码中Db类来自ThinkPHP5
  • 由于nonce表数据会越来越大请定期根据 nonce_expire_time 删除
  • 考虑到性能问题 nonce 可使用redis方式实现
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,686评论 6 492
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,668评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 158,160评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,736评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,847评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,043评论 1 291
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,129评论 3 410
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,872评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,318评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,645评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,777评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,470评论 4 333
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,126评论 3 317
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,861评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,095评论 1 267
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,589评论 2 362
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,687评论 2 351

推荐阅读更多精彩内容