OAuth 回顾小结

突然发现没啥好写了,从 OAuth1.0 到 1.0a,再到 OAuth2.0,现在街上 OAuth 的授权原理和流程介绍已经很多了,哪天有空再把一些安全或漏洞补一下吧。

这里有详细的说明:OAuth 1.0/1.0a/2.0 的之间的区别有哪些?

可能想说的点:

  • OAuth 1.0 有什么漏洞?1.0a 修复了哪些问题?(回跳地址劫持)
  • OAuth 2.0 比 1.0 改变了哪些?为啥要用 HTTPS?
  • OAuth 2.0 的 state 字段的作用?(如何防止 CSRF)
  • RefreshToken 的意义?(AccessToken 一般有效期为2小时,RefreshToken 有效期7天)
  • 授权作用域 scopes 的应用

以下是 2020.05.08 的回顾:

OAuth 授权流程如下:

  1. A 网站让用户跳转到 GitHub。
  2. GitHub 要求用户登录(没登录则先登录),然后询问”A 网站要求获得 xx 权限,你是否同意?”
  3. 用户同意,GitHub 就会重定向回 A 网站,同时发回一个授权码。
  4. A 网站使用授权码,向 GitHub 请求令牌。
  5. GitHub 返回令牌.
  6. A 网站使用令牌,向 GitHub 请求用户数据。

OAuth 授权代码演示

1、A 网站让用户跳转到 GitHub。

A 网站拼接一个 authorizeUrl 跳转去 GitHub

https://github.com/oauth/authorize?
  response_type=code&
  client_id=CLIENT_ID&
  redirect_uri=CALLBACK_URL&
  scope=read
  state=随机数

2、GitHub 要求用户登录(没登录则先登录),然后询问”A 网站要求获得 xx 权限,你是否同意?”

3、用户同意,GitHub 就会重定向回 A 网站,同时发回一个授权码。

function getAuthCodeByClientId(
    int $uid,
    string $clientId,
    string $redirectUri,
    array $scopes = [],
)
{
    // 1、检测 clientId 是否存在
    $clientInfo = OAuthClient::find($clientId);

    // 2、回调地址必须一致
    if ($clientInfo->redirect_uri != $redirectUri) {
        // 验证失败
        return false;
    }

    // 3、生成授权码
    $authCode = OAuthAuthCode::create([
        'auth_code' => '随机生成',
        'client_id' => $clientId,
        'uid'       => $uid,
        'scopes'    => $scopes,
        'revoked'   => 0,
    ]);

    return $authCode;
}

GitHub 通过 URL 跳转把 authCode 传回给 A 网站:

https://{redirect_uri}/?
  code=code
  state=

4、A 网站使用授权码,向 GitHub 请求令牌 + 5、GitHub 返回令牌

服务端间 API 访问:

function getAccessTokenByAuthCode(string $clientId, string $clientSecret, string $authCode)
{
    // 1、检测 clientId 和 clientSecret 是否正确
    $clientInfo = OAuthClient::find($clientId);

    if ($clientInfo->client_secret != $clientSecret) {
        // 验证失败
        return false;
    }

    // 2、拿 code 去 auth_codes 表查出单条记录
    $codeInfo = OAuthAuthCode::find($authCode);

    if ($codeInfo->client_id != $clientId) {
        // 验证失败
        return false;
    }

    // 3、创建 accessToken
    $accessTokenInfo = OAuthAccessToken::create([
        'access_token' => '随机生成',
        'expires_in'   => 86400 * 7,
        'uid'          => $codeInfo->uid,
        'scopes'       => $codeInfo->scopes,
        'revoked'      => 0,
        'created_at'   => now(),
    ]);

    // 4、创建 refreshToken
    $refreshTokenInfo = OAuthRefreshToken::create([
        'refresh_token' => '随机生成',
        'access_token' => '随机生成',
        'expires_in'   => 86400 * 7,
        'scopes'       => $codeInfo->scopes,
        'revoked'      => 0,
        'created_at'   => now(),
    ]);

    return [
        $accessTokenInfo,
        $refreshTokenInfo,
    ];
}

6、A 网站使用令牌,向 GitHub 请求用户数据。

function getUserInfoByAccessToken(string $accessToken)
{
    // 1. 检测 accessToken 是否存在、是否过期
    $accessTokenInfo = OAuthAccessToken::find($accessToken);

    // 2、检测该 accessToken 的 scopes 是否可访问 userInfo
    if (in_array('sns_userinfo', $accessTokenInfo->scopes)) {
        // 无权访问
        return false;
    }

    // 3、取回 accessToken 对应的 UID
    $uid = $accessTokenInfo->uid;

    // 4、根据 UID 取用户信息并返回
    $userInfo = getUserInfoByUid($uid);

    return $userInfo;
}

OAuth Server 服务端表结构设计

clients

字段 类型 说明
client_id String 应用 ID (PK)
client_secret String 应用密钥
redirect_uri String 回调地址或域名

users

字段 类型 必填
uid Int 用户 UID (PK)
nickname String 昵称
avatar_url String 头像

auth_codes

字段 类型 必填
code String 授权码 (PK)
client_id String 应用 ID
uid Int 用户 UID
scopes ARRAY 已授权访问作用域
revoked Boolean 是否已使用
created_at Timestamp 创建使用

access_tokens

字段 类型 必填
access_token String 访问令牌 (PK)
client_id String 应用 ID
uid Int 用户 UID
expires_in Int 几秒后过期
expires_at TIMESTAMP 过期时间戳
scopes ARRAY 已授权访问作用域(从 auth_codes 表冗余 )
revoked Boolean 是否废弃
created_at Timestamp 创建使用

refresh_tokens

字段 类型 必填
refresh_tokens String 访问令牌 (PK)
access_token String 访问令牌
expires_in Int 几秒后过期
revoked Boolean 是否废弃
created_at Timestamp 创建使用

OAuth 和 SSO 的区别

SSO 非常像 OAuth 的 隐藏式 授权码 implicit 方式,下面介绍下 OAuth 的这种隐式授权码方式:

第一步、A 网站提供一个链接,要求用户跳转到 B 网站,授权用户数据给 A 网站使用。

https://b.com/oauth/authorize?
  response_type=token&
  client_id=CLIENT_ID&
  redirect_uri=CALLBACK_URL&
  scope=read

上面 URL 中,response_type 参数为 token,表示要求直接返回令牌。

第二步、用户跳转到 B 网站,登录后同意给予 A 网站授权。这时,B 网站就会跳回 redirect_uri 参数指定的跳转网址,并且把令牌作为 URL 参数,传给 A 网站。

https://a.com/callback#token=ACCESS_TOKEN

上面 URL 中,token 参数就是令牌,A 网站因此直接在前端拿到令牌。

注意,令牌的位置是 URL 锚点(fragment),而不是查询字符串(querystring),这是因为 OAuth 2.0 允许跳转网址是 HTTP 协议,因此存在”中间人攻击”的风险,而浏览器跳转时,锚点不会发到服务器,就减少了泄漏令牌的风险。

总结区别:OAuth 和 SSO 都可以做统一认证登录,但是 OAuth 可用于授权其他资源,SSO 只能登录认证。

OAuth 中 state 参数的作用

一句话:防止 CSRF,类似于表单 FormHash

假设有场景「A网站」,在个人资料页有「绑定 GitHub」按钮,最后授权完 GitHub 回跳「A 网站」的 URL 是:

https://a.com?code=xxx

先说正常流程:此时「A网站」会用code 去换 accessToken 再换 GithubUserInfo,然后将当前用户张三 uid 与 GitHub 的 thirdUid 进行绑定,以后张三就可以用 GitHub 账号来登录「A网站」。

怎么破坏?

  1. 恶意者拿着用自己 GitHub 账号正常授权后获得的带 code 的链接 URL(抓包截断),发给张三
  2. 张三打开这个 URL,就会上面逻辑,将自己的「A网站」UID 与恶意者的 GitHub 的 thirdUid 绑定了
  3. 后果:恶意者可以用自己的 GitHub 账号登录「A网站」(登录进去是以张三的身份)

当然,这种破坏只对张三之前没绑定过 GitHub 这种情况有效,如果已绑过,那么这种破坏方式会失效。

怎么防范?

  1. 在有「绑定 GitHub」按钮的页面渲染时,生成一个随机字符串 state 存在服务端 session
  2. 把这个 state 放到 authorize_url 里,等授权完成跳回来到本站时,也带回这个 state
  3. 因为这一步时浏览器跳转,所以请求头里有 cookiePHP_SESSID
  4. 服务端从 session 中拿出之前存出的 stateredirect_uri 里带回的 state 做对比,如果一致,说明本次请求并非伪造。

MySQL Replication 主从同步原理

Alt text

一共有三个线程:

  1. 主库上的 IO 线程
  2. 从库上的 IO 线程
  3. 从库上的 SQL 线程

大致描述一下过程:

从库的 IO 线程从主库获取二进制日志,并在本地保存为中继日志,然后通过SQL线程来在从上执行中继日志中的内容,从而使从库和主库保持一致。

  1. 主库验证连接。
  2. 主库为从库开启一个 IO 线程。
  3. 从库将主库日志的偏移位告诉主库。
  4. 主库检查该值是否小于当前二进制日志偏移位。
  5. 如果小于,则通知从库来取数据。
  6. 从库持续从主库取数据,直至取完,这时,从库线程进入睡眠,主库线程同时进入睡眠。
  7. 当主库有更新时,主库线程被激活,并将二进制日志推送给从库,并通知从库线程进入工作状态。
  8. 从库SQL线程执行二进制日志,随后进入睡眠状态。

详细的基本交互过程:

  1. slave 端的 IO 线程连接上 master 端,并请求从指定 binlog 日志文件的指定 pos 节点位置(或者从最开始的日志)开始复制之后的日志内容。

  2. master 端在接收到来自 slave 端的 IO 线程请求后,通知负责复制进程的 IO 线程,根据 slave 端 IO 线程的请求信息,读取指定 binlog 日志指定 pos 节点位置之后的日志信息,然后返回给 slave 端的 IO 线程。该返回信息中除了 binlog 日志所包含的信息之外,还包括本次返回的信息在 master 端的 binlog 文件名以及在该 binlog 日志中的 pos 节点位置。

  3. slave 端的 IO 线程在接收到 master 端 IO 返回的信息后,将接收到的 binlog 日志内容依次写入到 slave 端的 relaylog 文件(mysql-relay-bin.xxxxxx)的最末端,并将读取到的 master 端的 binlog 文件名和 pos 节点位置记录到 master-info(该文件存在 slave 端)文件中,以便在下一次读取的时候能够清楚的告诉 master “我需要从哪个 binlog 文件的哪个 pos 节点位置开始,请把此节点以后的日志内容发给我”。

  4. slave 端的 SQL 线程在检测到 relaylog 文件中新增内容后,会马上解析该 log 文件中的内容。然后还原成在 master 端真实执行的那些 SQL 语句,并在自身按顺丰依次执行这些SQL语句。这样,实际上就是在 master 端和 slave 端执行了同样的 SQL 语句,所以 master 端和 slave 端的数据是完全一样的。

以前的 MySQL 里,Slave 端的复制是由单独的一个 IO 线程来完成的(简而言之就是没有用 SQL Relay 队列)。这样存在几个问题:

  1. 从库上的 IO 线程要串行完成一系列操作:拉取+解析+执行 master binlog,执行时间比较长。

  2. 执行延迟越长,主库数据丢失的风险就越大,主库是希望从库尽可能零延迟、无间歇地来拉取数据的。一旦存在较长拉取延迟,那么在此期间 master 挂了,还没来得及被从库拉取的那一部分数据,将永远的丢失,所以我们要想办法尽可能地提高从库拉取数据的频次和效率。

  3. 新版将 Slave 端的复制改为两个线程来完成,IO 线程只负责从主库读取 SQL 并推到中继日志队列里就完事,SQL 线程则只负责从中继日志队列里依次弹出并执行SQL。这样缩短了从库的延迟时间,就减少了潜在的丢失数据的风险。

  4. 当然,即使是换成了现在这样两个线程来协作处理之后,Slave 仍然存在丟数据的可能,毕竟复制是异步的,只要数据的更改不是在一个事务中,这些问题都是存在的。

Memcached 的一些小结

阶段一哈希(客户端做,负责找出目标服务器)

阶段二哈希(服务端运算,负责根据键找出值)

memcache key 的长度最大为 250 字符,value 长度最大为1M

Memcache VS MySQL Query Cache

  1. MySQL Query Cache 是表级别,只要表中数据有变化,跟表有关的所有 Query Cache 都被清除
  2. Memcache 更加灵活,除了缓存 SQL 结果集外,还可以缓存更丰富的数据集合(例如玩家名片信息)
  3. Memcache 集群水平扩展成本低,要加内存,只需要加一堆廉价PC机,
  4. MySQL Query Cache 只能缓存到本机,如果加内存,成本高,扩展不方便

同步、异步、阻塞、非阻塞的理解

同步与异步

同步和异步关注的是消息通信机制 (synchronous communication/ asynchronous communication)

所谓同步,就是在发出一个调用时,由调用者主动等待这个调用的结果。

而异步则是相反,调用在发出之后,调用者不会立刻得到结果。 而是在调用发出后,被调用者通过状态、通知来通知调用者,或通过回调函数处理这个调用。

典型的异步编程模型比如 Node.js

阻塞与非阻塞

阻塞和非阻塞关注的是程序在等待调用结果时的自身的状态.

阻塞调用是指调用结果返回之前,当前线程会被挂起。调用线程只有在得到结果之后才会返回。 非阻塞调用指在不能立刻得到结果之前,该调用不会阻塞当前线程。

在处理 IO 的时候,阻塞和非阻塞都是同步 IO。只有使用了特殊的 API 才是异步 IO。

举个栗子

老张爱喝茶,废话不说,煮开水。出场人物:老张,水壶两把(普通水壶,简称水壶;会响的水壶,简称响水壶)。

1、老张把水壶放到火上,立等水开。(同步阻塞)老张觉得自己有点傻

2、老张把水壶放到火上,去客厅看电视,时不时去厨房看看水开没有。(同步非阻塞)老张还是觉得自己有点傻,于是变高端了,买了把会响笛的那种水壶。水开之后,能大声发出嘀~~~~的噪音。

3、老张把响水壶放到火上,立等水开。(异步阻塞)老张觉得这样傻等意义不大

4、老张把响水壶放到火上,去客厅看电视,水壶响之前不再去看它了,响了再去拿壶。(异步非阻塞)老张觉得自己聪明了。

所谓同步异步,只是对于水壶而言。普通水壶,同步;响水壶,异步。虽然都能干活,但响水壶可以在自己完工之后,提示老张水开了。这是普通水壶所不能及的。同步只能让调用者去轮询自己(情况2中),造成老张效率的低下。

所谓阻塞非阻塞,仅仅对于老张而言。立等的老张,阻塞;看电视的老张,非阻塞。情况1和情况3中老张就是阻塞的,媳妇喊他都不知道。虽然3中响水壶是异步的,可对于立等的老张没有太大的意义。所以一般异步是配合非阻塞使用的,这样才能发挥异步的效用。

参考文章:

MySQL 字段类型 varchar 和 text 类型的区别

MySQL 字段类型 varchar 和 text 类型的区别

  1. text 不允许有默认值,varchar允许有默认值
  2. varchar 可以替代 tinytext
  3. 如果存储的数据大于64K,就必须使用到 mediumtext/longtext
  4. varchar(255+) 和 text 在存储机制是一样的
  5. varchar(65535+) 和 mediumtext 在存储机制是一样的

需要特别注意varchar(255)不只是255byte ,实质上有可能占用的更多。

特别注意,varchar大字段一样的会降低性能,所以在设计中还是一个原则大字段要拆出去,主表还是要尽量的瘦小

参考原文:http://wubx.net/varchar-vs-text/

flickr 的全局主键生成方案

flickr 的全局主键生成方案

类似于大航海的的数据库设计,我们的用户分库有 voyage_1/2/3/4 … 那么uid怎样生成?

现在的做法是在用一张索引表 voyage_share.user_index 取其自增主键,insert_id 便是uid。但缺点是,有单点负载的风险。

flickr提供了一个扩展的更好的方案: 他们把 user_index 抽出一个专门用作生成 uid 的表,例如取名叫 uid_sequence,并拆成若干的字表,自增步长设置为2(机器数目),这两张表可以放在不同的物理机器上。 其中一个表负责生成奇数uid,另一个负责生成偶数uid

uid_sequence 表的设计

比如创建64位的自增id:

CREATE TABLE `uid_sequence` (
  `id` bigint(20) unsigned NOT NULL auto_increment,
  `stub` char(1) NOT NULL default '',
  PRIMARY KEY  (`id`),
  UNIQUE KEY `stub` (`stub`)
) ENGINE=MyISAM;

SELECT * from uid_sequence 输出:

+-------------------+------+
| id                | stub |
+-------------------+------+
| 72157623227190423 |    a |

如果我需要一个全局的唯一的64位uid,则执行:

REPLACE INTO uid_sequence (stub) VALUES ('a');
SELECT LAST_INSERT_ID();
  • 用 REPLACE INTO 代替 INSERT INTO 的好处是避免表行数太大,还要另外定期清理。
  • stub 字段要设为唯一索引,这个 sequence 表只有一条纪录,但也可以同时为多张表生成全局主键,例如 user_ship_id。除非你需要表的主键是连续的,那么就另建一个 user_ship_id_sequence 表。
  • 经过实际对比测试,使用 MyISAM 比 Innodb 有更高的性能。

这里flickr使用两台数据库作为自增序列生成,通过这两台机器做主备和负载均衡。

TicketServer1:
auto-increment-increment = 2
auto-increment-offset = 1

TicketServer2:
auto-increment-increment = 2
auto-increment-offset = 2

MySQL 中 last_insert_id() 的并发问题

因为是两条SQL语句,所以这两条语句之间会不会有并发问题?

答案是不会,因为 last_insert_id() 是 Connection 级别的,是单个连接客户端里执行的insert语句最近一条,客户端之间是不会影响,没必要锁定和事务处理。