笔者日常会使用两台电脑和两台手机,在这几台设备上都需要访问 Typecho 的管理后台。但 Typecho 内置的登录机制是会“顶号”的——对每个用户,当成功登录时,上一个登录态会失效,这多少导致了一些不便。本文讨论一种能让多台设备同时获得有效的 Typecho 登录态的方法。
分析源码
以下源码参考 Typecho 1.2.1 版本,其他版本的实现可能有所不同。
成功登录时
- 为成功登录的用户生成一个新的 authCode,保存到数据库中
- 设置 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'])); }
验证登录态
- 根据 Cookie 中的
uid
从数据库中查询用户信息 - 校验 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); } }
- 根据 Cookie 中的
多端登录原理
结合前文所述,可以推测 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。
操作步骤:
- 在导出端的浏览器访问你的 Typecho 博客并登录
在浏览器地址栏,粘贴下面的导出脚本,按回车键执行
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);
- 根据弹窗提示输入一个用于加密 Cookies 的密钥
浏览器将弹窗显示一个导入脚本,将其复制出来
- 在导入端(未登录 Typecho)的浏览器访问你的博客首页
- 在浏览器地址栏,粘贴导入脚本,按回车键执行
- 根据提示输入相同的密钥(用于解密 Cookies)
- 若密钥输入正确,浏览器会提示“Cookies 设置成功,请刷新页面”;若提示“解密失败”,请检查输入的密钥是否正确
注:一些常见的浏览器为了降低风险,在地址栏粘贴以 javascript:
为前缀的 URL 时会自动去掉这个前缀,您可以先在地址栏输入 javascript:
,然后再将脚本代码粘贴进来。
风险提示
- 请注意保管好您的博客登录态 Cookies,避免将博客登录态 Cookies 通过不安全的途径进行传输
- 本文提供的代码将复制的登录态 Cookies 有效期置为 1 年,这会使得登录态的有效期被延长
大佬有这个技术为啥不自己开发一个博客!!
个人认为博客的核心功能(文章发布、评论)以及内容是第一顺位去考虑的,其次才是额外功能以及外观等等。前者的话基于现有的博客程序去做是比较稳妥而且时间成本较低的,后者的话我目前是基于 Typecho 的外观和插件能力在做。
支持大佬,要是能写个插件就方便使用了 😄😄
用wordpress吧,没有这个烦恼。
Passkey让Typecho可以指纹或人脸登陆插件
https://store.typecho.work/archives/Passkey-typecho-plugins.html
这个你可以看下,虽然收费插件,哈哈
让博客在浏览器中可以使用Passkey登录,在后台添加好通行密钥后,可以调用设备使用指纹登录,人脸登录,电脑密码登录等方式登录网站,使其不用记住繁琐的网站账号密码。插件记录不到指纹和人脸信息的这点请放心。该插件会让您的网站更加安全且便捷。
这个看起来不错欸。曾经我是完全不会考虑付费插件和付费主题的,宁愿自己花时间去实现;到了现在可以花在博客上的时间没那么多了,用金钱换时间突然就有了性价比😤
建议写成插件让我方便使用哈哈哈哈哈
确实有考虑过写一个插件,一步到位让 Typecho 支持多端登录,并且可以分别管理每台设备上的登录态这样。不过现在既写代码又写博客,要忙不过来啦 😂
忙点好,像我每天都不知道干点啥了
好方法,我是使用自建的 CookieCloud 直接同步了全部 cookie ,大力出奇迹😂😂
原来还有这种解决方案,我去看下。这种也挺好的,一次搭建解决多台设备多个网站的同步了。
我是因为每天在电脑手机来回登录 Typecho 感觉太麻烦了才折腾的,其他网站一般都支持多台设备同时登录,对同步的需求倒不是特别强烈。