Typecho 多端共享登录态

笔者日常会使用两台电脑和两台手机,在这几台设备上都需要访问 Typecho 的管理后台。但 Typecho 内置的登录机制是会“顶号”的——对每个用户,当成功登录时,上一个登录态会失效,这多少导致了一些不便。本文讨论一种能让多台设备同时获得有效的 Typecho 登录态的方法。

分析源码

以下源码参考 Typecho 1.2.1 版本,其他版本的实现可能有所不同。

  1. 成功登录时

    1. 为成功登录的用户生成一个新的 authCode,保存到数据库中
    2. 设置 2 个 Cookies(__typecho_uid 以及 __typecho_authCode

    var/Widget/User.php

    /**
     * @param $user
     * @param int $expire
     * @throws DbException
     */
    public function commitLogin(&$user, int $expire = 0)
    {
        $authCode = function_exists('openssl_random_pseudo_bytes') ?
            bin2hex(openssl_random_pseudo_bytes(16)) : sha1(Common::randString(20));
        $user['authCode'] = $authCode;
    
        Cookie::set('__typecho_uid', $user['uid'], $expire);
        Cookie::set('__typecho_authCode', Common::hash($authCode), $expire);
    
        //更新最后登录时间以及验证码
        $this->db->query($this->db
            ->update('table.users')
            ->expression('logged', 'activated')
            ->rows(['authCode' => $authCode])
            ->where('uid = ?', $user['uid']));
    }
  2. 验证登录态

    1. 根据 Cookie 中的 uid 从数据库中查询用户信息
    2. 校验 Cookie 中的 authCode 和数据库中的 authCode 是否匹配(Cookie 中的 authCode 是 hash 过的,实际是对数据库中该用户的 authCode 用相同方法计算 hash,然后验证是否一致)

    var/Widget/User.php

    /**
     * 判断用户是否已经登录
     *
     * @return boolean
     * @throws DbException
     */
    public function hasLogin(): ?bool
    {
        if (null !== $this->hasLogin) {
            return $this->hasLogin;
        } else {
            $cookieUid = Cookie::get('__typecho_uid');
            if (null !== $cookieUid) {
                /** 验证登陆 */
                $user = $this->db->fetchRow($this->db->select()->from('table.users')
                    ->where('uid = ?', intval($cookieUid))
                    ->limit(1));
    
                $cookieAuthCode = Cookie::get('__typecho_authCode');
                if ($user && Common::hashValidate($user['authCode'], $cookieAuthCode)) {
                    $this->currentUser = $user;
                    return ($this->hasLogin = true);
                }
    
                $this->logout();
            }
    
            return ($this->hasLogin = false);
        }
    }

多端登录原理

结合前文所述,可以推测 Typecho 的登录态仅和以下 2 个 Cookie 有关:

  • $prefix__typecho_uid
  • $prefix__typecho_authCode

注:通过 Typecho 的 Cookie::set 函数设置的 Cookie 会带有一个前缀(本文以 $prefix 指代),前缀值为 md5($options->siteUrl),所以不同站点的 Cookie 前缀是不同的。

因此,在一台设备的浏览器上登录 Typecho 之后,将这些 Cookie 复制到另外一台设备的浏览器上,就可以实现 Typecho 的多端登录了。

需要注意的是,这几台设备上的登录态是完全相同的,这意味着当其中一端点击登出使得这个登录态失效时,所有相关设备上的登录态会同时失效。

多端登录实现

这里我们用 JavaScript 代码来实现 Typecho 登录态 Cookies 的转移。为了避免明文传输 Cookies 的风险,会使用一个密钥对 Cookie 进行加解密。

导出登录态

(async function() {
  // 获取原始密钥字符串
  const password = prompt('请输入密钥(用于加密 Cookies)');
  if (!password) return;

  // 派生 AES 的密钥
  const enc = new TextEncoder();
  const keyMaterial = await window.crypto.subtle.importKey(
    'raw',
    enc.encode(password),
    { name: 'PBKDF2' },
    false,
    ['deriveKey']
  );
  const key = await window.crypto.subtle.deriveKey(
    {
      name: 'PBKDF2',
      salt: new Uint8Array(16), // 使用一个固定的salt
      iterations: 100000,
      hash: 'SHA-256',
    },
    keyMaterial,
    { name: 'AES-GCM', length: 256 },
    true,
    ['encrypt', 'decrypt']
  );

  // 获取 Cookies
  const cookies = document.cookie.split('; ').filter(cookie => cookie.includes('_typecho_uid') || cookie.includes('_typecho_authCode'));

  // 加密 Cookies
  const data = new TextEncoder().encode('COOKIES:' + cookies.join('; '));
  const encryptedData = await window.crypto.subtle.encrypt(
    {
      name: 'AES-GCM',
      iv: new Uint8Array(12) // 使用一个固定的 IV
    },
    key,
    data
  );

  // base64 encode
  const base64EncryptedData = btoa(String.fromCharCode.apply(null, new Uint8Array(encryptedData)));

  // 展示加密后的 Cookies
  prompt('加密 Cookies,请复制', base64EncryptedData);
})();

导入登录态

(async function () {
  // 获取原始密钥字符串
  const base64EncryptedData = prompt('请粘贴加密 Cookies');
  if (!base64EncryptedData) return;

  // 获取原始密钥字符串
  const password = prompt('请输入密钥(用于解密 Cookies)');
  if (!password) return;

  // 派生 AES 的密钥
  const enc = new TextEncoder();
  const keyMaterial = await window.crypto.subtle.importKey(
    'raw',
    enc.encode(password),
    { name: 'PBKDF2' },
    false,
    ['deriveKey']
  );
  const key = await window.crypto.subtle.deriveKey(
    {
      name: 'PBKDF2',
      salt: new Uint8Array(16),
      iterations: 100000,
      hash: 'SHA-256',
    },
    keyMaterial,
    { name: 'AES-GCM', length: 256 },
    true,
    ['encrypt', 'decrypt']
  );

  // 解密 Cookies
  try {
    const encryptedData = new Uint8Array(Array.from(atob(base64EncryptedData), c => c.charCodeAt(0)));
    const decryptedData = await window.crypto.subtle.decrypt(
      {
        name: 'AES-GCM',
        iv: new Uint8Array(12)
      },
      key,
      encryptedData
    );

    const decryptedText = new TextDecoder().decode(decryptedData);
    if (!decryptedText.startsWith('COOKIES:')) {
      throw new Error('magic number not match');
    }
    
    // 设置 Cookies
    const decryptedCookies = decryptedText.substring(8).split('; ');
    const domain = location.host;
    const expires = new Date(); 
    expires.setFullYear(expires.getFullYear() + 1); // 设置有效期为当前时间的一年后

    decryptedCookies.forEach(cookie => {
        document.cookie = `${cookie}; Path=/; Domain=${domain}; Expires=${expires.toUTCString()};`;
    });

    alert('Cookies 设置成功,请刷新页面');
  } catch (e) {
    alert('解密失败,请检查密钥是否正确');
    console.error(e);
  }
})();

一键脚本

这里提供一段可以输入到浏览器的地址栏中执行的一键导出脚本。它的原理和功能与上述源代码一致,但略有不同的是,这个导出脚本会直接输出包含了加密 Cookies 的导入脚本(但不包含用于加密的 Cookies 的密钥),因此您不需要分别复制导入脚本和加密 Cookies。

操作步骤:

  1. 在导出端的浏览器访问你的 Typecho 博客并登录
  2. 在浏览器地址栏,粘贴下面的导出脚本,按回车键执行

    javascript:!async function(){const e=prompt("请输入密钥(用于加密 Cookies)");if(!e)return;const t=new TextEncoder,r=await window.crypto.subtle.importKey("raw",t.encode(e),{name:"PBKDF2"},!1,["deriveKey"]),n=await window.crypto.subtle.deriveKey({name:"PBKDF2",salt:new Uint8Array(16),iterations:1e5,hash:"SHA-256"},r,{name:"AES-GCM",length:256},!0,["encrypt","decrypt"]),o=document.cookie.split("; ").filter((e=>e.includes("_typecho_uid")||e.includes("_typecho_authCode"))),a=(new TextEncoder).encode("COOKIES:"+o.join("; ")),i=await window.crypto.subtle.encrypt({name:"AES-GCM",iv:new Uint8Array(12)},n,a),c=btoa(String.fromCharCode.apply(null,new Uint8Array(i)));prompt("请复制到另一浏览器的地址栏中,按回车键执行",'javascript:!async function(){const e="{ENCRYPTED_COOKIES}";const t=prompt("请输入密钥(用于解密 Cookies)");if(!t)return;const r=new TextEncoder,o=await window.crypto.subtle.importKey("raw",r.encode(t),{name:"PBKDF2"},!1,["deriveKey"]),n=await window.crypto.subtle.deriveKey({name:"PBKDF2",salt:new Uint8Array(16),iterations:1e5,hash:"SHA-256"},o,{name:"AES-GCM",length:256},!0,["encrypt","decrypt"]);try{const t=new Uint8Array(Array.from(atob(e),(e=>e.charCodeAt(0)))),r=await window.crypto.subtle.decrypt({name:"AES-GCM",iv:new Uint8Array(12)},n,t),o=(new TextDecoder).decode(r);if(!o.startsWith("COOKIES:"))throw new Error("magic number not match");const a=o.substring(8).split("; "),i=location.host,c=new Date;c.setFullYear(c.getFullYear()+1),a.forEach((e=>{document.cookie=`${e}; Path=/; Domain=${i}; Expires=${c.toUTCString()};`})),alert("Cookies 设置成功,请刷新页面")}catch(e){alert("解密失败,请检查密钥是否正确"),console.error(e)}}();void(0);'.replace("{ENCRYPTED_COOKIES}",c))}();void(0);
  3. 根据弹窗提示输入一个用于加密 Cookies 的密钥
  4. 浏览器将弹窗显示一个导入脚本,将其复制出来


  5. 在导入端(未登录 Typecho)的浏览器访问你的博客首页
  6. 在浏览器地址栏,粘贴导入脚本,按回车键执行
  7. 根据提示输入相同的密钥(用于解密 Cookies)
  8. 若密钥输入正确,浏览器会提示“Cookies 设置成功,请刷新页面”;若提示“解密失败”,请检查输入的密钥是否正确

注:一些常见的浏览器为了降低风险,在地址栏粘贴以 javascript: 为前缀的 URL 时会自动去掉这个前缀,您可以先在地址栏输入 javascript:,然后再将脚本代码粘贴进来。

风险提示

  • 请注意保管好您的博客登录态 Cookies,避免将博客登录态 Cookies 通过不安全的途径进行传输
  • 本文提供的代码将复制的登录态 Cookies 有效期置为 1 年,这会使得登录态的有效期被延长