简易 Token 验证的实现

前言

在我们的服务器和客户端的交互中,由于我们的业务中使用 RESTful API 的形式和客户端交互,而 API 又是无状态的,无法帮助我们识别这一次和上一次的请求由谁发出、是否合法,因此我们需要想一个办法来确认用户身份,检查是否请求合法,经调研,较为流行的解决方式是使用 Token 进行验证。

我将介绍如何设计实现一个简单的 Token 验证逻辑,本文的说法仅是基于我自己的一点想法和参考来的知识,如有谬误麻烦不吝指出。

参考资料

基于 Token 的 WEB 后台认证机制:https://www.cnblogs.com/xiekeli/p/5607107.html

Token 设计

有了密码认证为什么需要 Token?

Token 就像一把钥匙,当用户登录之后,服务器就把这把钥匙随着返回的 json 包发送给用户,用户在接下来的请求中,涉及到需要验证身份和权限的,就按照和服务器的约定,把这把钥匙放在请求包合适的地方,随着请求包一起发送,服务器检查 Token 是否合法有效以确认身份,然后决定要执行请求的操作还是拒绝服务并返回错误。这样就用可过期的 Token 替代了每次需要验证身份时都需要发送的重要的、不变的密码。

使用 Token 还有一些其他的优点,例如参考资料中提到的 Token 机制相对于 Cookie 机制有支持跨域访问、无状态 (也称:服务端可扩展行)、更适用 CDN、去耦、更适用于移动应用、CSRF、性能、不需要为登录页面做特殊处理、基于标准化的优点。

Token 里有什么?

Token 的目的是用于表明身份,所以它应该包含一些独特的、只属于此用户的、不容易伪造的信息。例如,创建此 Token 的 Unix 时间戳、用户的唯一 id、唯一账号、经过特殊算法生成的用户识别码等等。但也不能包含一些敏感信息,比如用户的密码。如果用户的明文密码存在于 Token 中,那么有心人劫取并解析 Token 后,就可以直接登录了。

安全性

由上所述,安全性是我们需要考虑的很重要的一部分,我们面临的安全风险主要有跨站脚本攻击(XSS(Cross Site Scripting)Attacks)、请求篡改、重放攻击(Replay Attacks)、中间人攻击(MITM(Man-In-The-Middle)Attacks)。

由于我们使用 RESTful API 的形式,应该不需要考虑 XSS 攻击的事情,只要注意客户端传上来的 json 包内容合法安全即可。如果你有网页需要显示,在 PHP 中使用 htmlspecialchars 函数来避免 XSS 攻击。

请求篡改和中间人攻击的问题,我们可以通过利用 SSL/TLS 来加密数据包,也就是使用 HTTPS。

参考资料中介绍的重放攻击概念如下:

所谓重放攻击就是攻击者发送一个目的主机已接收过的包,来达到欺骗系统的目的,主要用于身份认证过程。比如在浏览器端通过用户名 / 密码验证获得签名的 Token 被木马窃取。即使用户登出了系统,黑客还是可以利用窃取的 Token 模拟正常请求,而服务器端对此完全不知道,因为 JWT 机制是无状态的。

解决方式有:

  • 时间戳 + 共享秘钥
  • 时间戳 + 共享秘钥 + 黑名单

具体可以查看参考资料。

安全只能是相对而言,我们既然是实现简易的 Token 验证,那我认为达到防君子不防小人的效果应该算可以接受了,我们应该根据自己的需要来增强自身安全性,盲目追求安全是不可取的。

比较简单的增强安全性的方式是,给 Token 定义一个过期时间,若 Token 过期将被废弃,若没有过期时间,我认为 Token 就是另一种形式的密码而已。服务器可以参考包含在 Token 中的的过期时间决定是否返回「Token 过期」的错误消息,但要注意这时 Token 不能是明文的,且加密 / 混淆算法需要不可 / 难以破解,否则过期时间可能被伪造。因此我建议生成 Token 过期时间后,将它存于数据库中,验证时不参考 Token 中包含的过期时间(如果有的话)。

Token 明文当然也可,只要能够保证 Token 内容有识别意义且难以被伪造,但我们一般将 Token 信息(一般是一个数组)进行 base64 编码(我们很容易进行解码),以便于传输。

Token 过期后,我们可以要求用户重新登录来刷新 Token,或者提供接口让客户端自动刷新 Token。自动刷新 Token 需要一个 Refresh Token(刷新 Token),它一般和 Token 生成方式类似,但有效期更长(也可以永久有效)且只能用于刷新 Token,不能用于业务验证。

本文中,Token 和 Refresh Token 将是一个包含了 Token 过期时间、由 PHP 函数 uniqid 生成的 uniqid、用户唯一账号信息,并 base64 编码后的字符串,Token 过期时间为 7 天,Refresh Token 过期时间为 14 天,这些时间理论上越短越安全。

注意,以上的简易设计无法解决「重放攻击」,防范方式参考上文。

接口预设

注册接口

客户端将需要注册的账号密码随请求包发往服务器,若注册成功,服务器将为此用户初始化一组 Token、Refresh Token(刷新 Token)、Expire Time(Token 过期时间),并存于数据库。

登录接口

客户端将需要登录的账号密码随请求包发往服务器,若登录成功,服务器将返回用户此时的 Token、Refresh Token(刷新 Token)、Expire Time(Token 过期时间)。

更新用户 Token 接口

根据请求包中的用户刷新 Token 检查是否匹配和过期,若匹配成功且不过期,刷新用户的 Token、Refresh Token(刷新 Token)、Expire Time(Token 过期时间)并返回。

其他逻辑

  • 服务器应在每次验证 Token 时检查 Token 是否过期
  • 若刷新 Token 有过期时间,在验证刷新 Token 时也要检查,若过期应当要求用户重新登录且刷新用户的 Token、Refresh Token(刷新 Token)、Expire Time(Token 过期时间)

具体代码

在 PHP Laravel 环境下。

/**
 * 生成用户 Token、刷新 Token、Token 过期时间
 *
 * @param  User  $user
 * @return array $tokenInfo
 */
public function refreshToken(User $user)
{
  // config('app.token_expires_seconds') 是我们自己定义的 Token 过期时间
  $tokenExpireTime = date('Y-m-d H:i:s', time() + config('app.token_expires_seconds'));
  $accessTokenInfo = [
    'uniqid' => uniqid('', true),
    'account' => $user->account,
    'tokenExpireTime' => $tokenExpireTime
  ];
  $refreshTokenInfo = [
    'uniqid' => uniqid('', true),
    'account' => $user->account,
    'tokenExpireTime' => $tokenExpireTime
  ];
  $accessToken = base64_encode(implode(',', $accessTokenInfo));
  $refreshToken = base64_encode(implode(',', $refreshTokenInfo));

  $user->access_token = $accessToken;
  $user->access_refresh_token = $refreshToken;
  $user->access_token_expires_in = $tokenExpireTime;
  $user->save();

  $tokenInfo = [
    'access_token' => $accessToken,
    'refresh_token' => $refreshToken,
    'expire_time' => $tokenExpireTime
  ];
  return $tokenInfo;
}

/**
 * 注册账号
 *
 * @param  \Illuminate\Http\Request  $request
 * @return \Illuminate\Http\Response
 */
public function create(Request $request)
{
  $account = $request->input('account');
  $password = $request->input('password');

  $user = new User;
  $user->account = $account;
  $user->password = Hash::make($password);
  $user->save();

  $this->refreshToken($user);

  return response()->json([
    'error_code' => 200,
    'data' => [
      'user_id' => $user->id,
      'account' => $account
    ]
  ]);
}

/**
 * 登录账号
 *
 * @param  \Illuminate\Http\Request  $request
 * @return \Illuminate\Http\Response
 */
public function login(Request $request)
{
  $account = $request->input('account');
  $password = $request->input('password');

  $user = User::where('account', $account)->first();
  if (!$user) {
    return response()->json([
      'error_code' => 403,
      'error_message' => 'User not exist.'
    ]);
  }

  if (!Hash::check($password, $user->password)) {
    return response()->json([
      'error_code' => 401,
      'error_message' => 'Wrong password.'
    ]);
  }

  return response()->json([
    'error_code' => 200,
    'data' => [
      'user_id' => $user->id,
      'account' => $account,
      'access_token' => $user->access_token,
      'refresh_token' => $user->access_refresh_token,
      'expire_time' => $user->access_token_expires_in
    ]
  ]);
}

/**
 * 更新用户 Token
 *
 * @param  \Illuminate\Http\Request  $request
 * @return \Illuminate\Http\Response
 */
public function updateAccessToken(Request $request, User $user)
{
  $refreshToken = $request->header('Authorization');
  // Refresh token 验证
  if ($refreshToken != $user->access_refresh_token) {
    return response()->json([
      'error_code' => 401,
      'error_message' => 'Wrong access refresh token.'
    ]);
  }

  // 检查 Refresh token 过期(14 天过期)
  if (strtotime($user->access_token_expires_in)
    + config('app.token_expires_seconds') < time()) {
    $this->refreshToken($user);
    return response()->json([
      'error_code' => 403,
      'error_message' => 'Refresh token expired.'
    ]);
  }

  $tokenInfo = $this->refreshToken($user);

  return response()->json([
    'error_code' => 200,
    'data' => [
      'user_id' => $user->id,
      'access_token' => $tokenInfo['access_token'],
      'refresh_token' => $tokenInfo['refresh_token'],
      'expire_time' => $tokenInfo['expire_time']
    ]
  ]);
}

/**
 * 检查 Token
 *
 * @param  \Illuminate\Http\Request  $request
 * @return \Illuminate\Http\Response
 */
public function login(Request $request)
{
  $token = $request->header('Authorization');

  // Token 验证
  if ($token != $user->access_token) {
    return response()->json([
      'error_code' => 401,
      'error_message' => 'Wrong access token.'
    ]);
  }

  // 检查 Access token 过期(7 天过期)
  if (strtotime($user->access_token_expires_in) < time()) {
    return response()->json([
      'error_code' => 403,
      'error_message' => 'Access token expired.'
    ]);
  }
}

改进方式

  • 使用各语言 JWT 库进行 Token 验证
  • 使用 HTTPS
  • 更好的加密解密算法

comments powered by Disqus