Compare commits

...

6 Commits

Author SHA1 Message Date
CrescentLeaf
19b8b92f49 修改注释, 添加换行, 删除不必要的代码 2025-12-07 00:07:21 +08:00
CrescentLeaf
f584b49cd4 删除不必要的依赖 2025-12-07 00:06:40 +08:00
CrescentLeaf
13eefdd50c 自动温热一下身份 2025-12-07 00:06:32 +08:00
CrescentLeaf
3cd9031eef 使用更标准的 aes 加密写法, 更换密钥的算法, 限制 data 对象暴露 2025-12-07 00:06:02 +08:00
CrescentLeaf
94c901a233 ignore_all_empty?: boolean
this.refresh_token = args.refresh_token
/**
     * 进行身份验证以接受客户端事件
     *
     * 使用验证方式优先级: 访问 > 刷新 > 账号密码
     *
     * 若传递了账号密码, 则同时缓存新的访问令牌和刷新令牌
     *
     * 如只传递两个令牌的其一, 按照优先级并在成功验证后赋值
     *
     * 多个验证方式不会逐一尝试
     */
2025-12-07 00:03:24 +08:00
CrescentLeaf
1819c31267 updated vite to 7.2.6 2025-12-06 22:51:35 +08:00
7 changed files with 79 additions and 52 deletions

View File

@@ -131,33 +131,37 @@ export default class LingChairClient {
} }
} }
/** /**
* 客户端上线 * 进行身份验证以接受客户端事件
* *
* 使用验证方式优先级: 访问 > 刷新 > 账号密码 * 使用验证方式优先级: 访问 > 刷新 > 账号密码
* *
* 不会逐一尝试 * 若传递了账号密码, 则同时缓存新的访问令牌和刷新令牌
*
* 如只传递两个令牌的其一, 按照优先级并在成功验证后赋值
*
* 多个验证方式不会逐一尝试
*/ */
async authOrThrow(args: { async authOrThrow(args: {
refresh_token?: string, refresh_token?: string
access_token?: string, access_token?: string
account?: string, account?: string
password?: string, password?: string
ignore_all_empty?: boolean
}) { }) {
if ((!args.access_token && !args.refresh_token) && (!args.account && !args.password)) if ((!args.access_token && !args.refresh_token) && (!args.account && !args.password) && !args.ignore_all_empty)
throw new Error('Access/Refresh token or account & password required') throw new Error('Access/Refresh token or account & password required, or ignore_all_empty=true')
this.auth_cache = args this.auth_cache = args
this.refresh_token = args.refresh_token
let access_token = args.access_token let access_token = args.access_token
if (!access_token && args.refresh_token) { if (!access_token && args.refresh_token) {
const re = await this.invoke('User.refreshAccessToken', { const re = await this.invoke('User.refreshAccessToken', {
refresh_token: args.refresh_token, refresh_token: args.refresh_token,
}) })
if (re.code == 200) if (re.code == 200) {
access_token = re.data!.access_token as string | undefined access_token = re.data!.access_token as string | undefined
else this.refresh_token = args.refresh_token
} else
throw new CallbackError(re) throw new CallbackError(re)
} }
@@ -166,9 +170,10 @@ export default class LingChairClient {
account: args.account, account: args.account,
password: crypto.createHash('sha256').update(args.password).digest('hex'), password: crypto.createHash('sha256').update(args.password).digest('hex'),
}) })
if (re.code == 200) if (re.code == 200) {
access_token = re.data!.access_token as string | undefined access_token = re.data!.access_token as string | undefined
else this.refresh_token = re.data!.refresh_token as string
} else
throw new CallbackError(re) throw new CallbackError(re)
} }

View File

@@ -1,43 +1,56 @@
// @ts-types="npm:@types/crypto-js" import crypto from 'node:crypto'
import * as CryptoJS from 'crypto-js'
const dataIsEmpty = !localStorage.tws_data || localStorage.tws_data == '' const dataIsEmpty = !localStorage.tws_data || localStorage.tws_data == ''
const aes = { class Aes {
enc: (data: string, key: string) => CryptoJS.AES.encrypt(data, key).toString(), static randomIv() {
dec: (data: string, key: string) => CryptoJS.AES.decrypt(data, key).toString(CryptoJS.enc.Utf8), return crypto.randomBytes(12)
}
static normalizeKey(key: string, keyLength = 32) {
const hash = crypto.createHash('sha256')
hash.update(key)
const keyBuffer = hash.digest()
return keyLength ? keyBuffer.subarray(0, keyLength) : keyBuffer
}
static encrypt(data: string, key: string) {
const iv = this.randomIv()
return Buffer.concat([iv, crypto.createCipheriv("aes-256-gcm", this.normalizeKey(key), iv).update(data)]).toString('hex')
}
static decrypt(data: string, key: string) {
const buffer = Buffer.from(data, 'hex')
const iv = buffer.subarray(0, 12)
return crypto.createDecipheriv("aes-256-gcm", this.normalizeKey(key), iv).update(buffer.subarray(12)).toString()
}
} }
const key = location.host + '_TWS_姐姐' // 尽可能防止被窃取, 虽然理论上还是会被窃取
const key = crypto.createHash('sha256').update(location.host + '_TWS_姐姐_' + navigator.userAgent).digest().toString('base64')
if (dataIsEmpty) localStorage.tws_data = aes.enc('{}', key) if (dataIsEmpty) localStorage.tws_data = Aes.encrypt('{}', key)
let _dec = aes.dec(localStorage.tws_data, key) let _dec = Aes.decrypt(localStorage.tws_data, key)
if (_dec == '') _dec = '{}' if (_dec == '') _dec = '{}'
const _data_cached = JSON.parse(_dec) const _data_cached = JSON.parse(_dec)
// 類型定義 type IData = {
refresh_token?: string
split_sizes: number[]
apply(): void
access_token?: string
device_id: string
}
declare global { declare global {
interface Window { interface Window {
data: { data?: IData
refresh_token?: string
split_sizes: number[]
apply(): void
access_token?: string
device_id: string
}
} }
} }
// @ts-ignore: 忽略... const data = new Proxy({} as IData, {
// deno-lint-ignore no-window
(window.data == null) && (window.data = new Proxy({
apply() {}
}, {
get(_obj, k) { get(_obj, k) {
if (k == '_cached') return _data_cached if (k == '_cached') return _data_cached
if (k == 'apply') return () => localStorage.tws_data = aes.enc(JSON.stringify(_data_cached), key) if (k == 'apply') return () => localStorage.tws_data = Aes.encrypt(JSON.stringify(_data_cached), key)
return _data_cached[k] return _data_cached[k]
}, },
set(_obj, k, v) { set(_obj, k, v) {
@@ -45,7 +58,11 @@ declare global {
_data_cached[k] = v _data_cached[k] = v
return true return true
} }
})) })
// deno-lint-ignore no-window if (new URL(location.href).searchParams.get('export_data') == 'true') {
export default window.data window.data = data
console.warn("警告: 将 data 暴露到 window 有可能会导致令牌泄露!")
}
export default data

View File

@@ -1,7 +1,8 @@
import { LingChairClient } from 'lingchair-client-protocol' import { LingChairClient } from 'lingchair-client-protocol'
import data from "./Data.ts" import data from "./data.ts"
import { UAParser } from 'ua-parser-js' import { UAParser } from 'ua-parser-js'
import { randomUUID } from 'lingchair-internal-shared' import { randomUUID } from 'lingchair-internal-shared'
import performAuth from './performAuth.ts'
if (!data.device_id) { if (!data.device_id) {
const ua = new UAParser(navigator.userAgent) const ua = new UAParser(navigator.userAgent)
@@ -12,6 +13,11 @@ const client = new LingChairClient({
device_id: data.device_id, device_id: data.device_id,
auto_fresh_token: true, auto_fresh_token: true,
}) })
try {
await performAuth({})
} catch (_) {
console.log(_)
}
export default function getClient() { export default function getClient() {
return client return client

View File

@@ -6,8 +6,6 @@
"build-watch": "npx vite --watch build" "build-watch": "npx vite --watch build"
}, },
"dependencies": { "dependencies": {
"crypto-browserify": "3.12.1",
"crypto-js": "4.2.0",
"dompurify": "3.2.7", "dompurify": "3.2.7",
"lingchair-internal-shared": "*", "lingchair-internal-shared": "*",
"marked": "16.3.0", "marked": "16.3.0",
@@ -26,7 +24,7 @@
"@types/react-dom": "18.3.1", "@types/react-dom": "18.3.1",
"@vitejs/plugin-react": "4.7.0", "@vitejs/plugin-react": "4.7.0",
"chalk": "5.4.1", "chalk": "5.4.1",
"vite": "7.0.6", "vite": "7.2.6",
"vite-plugin-node-polyfills": "^0.24.0" "vite-plugin-node-polyfills": "^0.24.0"
} }
} }

View File

@@ -1,14 +1,16 @@
import data from "./Data.ts"; import data from "./data.ts"
import getClient from "./getClient.ts" import getClient from "./getClient.ts"
/** /**
* 尝试进行验证 * 进行身份验证以接受客户端事件
* *
* 成功后自动保存到本地 * 使用验证方式优先级: 访问 > 刷新 > 账号密码
* *
* 优先级: 账号密码 > 提供刷新令牌 > 储存的刷新令牌 * 若传递了账号密码, 则同时缓存新的访问令牌和刷新令牌
* *
* 不会逐一尝试 * 如只传递两个令牌的其一, 按照优先级并在成功验证后赋值
*
* 多个验证方式不会逐一尝试
*/ */
export default async function performAuth(args: { export default async function performAuth(args: {
refresh_token?: string refresh_token?: string
@@ -21,7 +23,7 @@ export default async function performAuth(args: {
password: args.password, password: args.password,
}) })
else { else {
await getClient().authOrThrow({ refresh_token: args.refresh_token ? args.refresh_token : data.refresh_token }) await getClient().authOrThrow({ refresh_token: args.refresh_token ? args.refresh_token : data.refresh_token, ignore_all_empty: true })
} }
data.refresh_token = getClient().getCachedRefreshToken() data.refresh_token = getClient().getCachedRefreshToken()
data.access_token = getClient().getCachedAccessToken() data.access_token = getClient().getCachedAccessToken()

View File

@@ -29,4 +29,4 @@ export default function AvatarMySelf({
}) })
return <Avatar avatarRef={avatarRef} {...props} {...args}></Avatar> return <Avatar avatarRef={avatarRef} {...props} {...args}></Avatar>
} }

View File

@@ -8,12 +8,11 @@ import MainSharedContext from '../MainSharedContext.ts'
export default function LoginDialog({ ...props }: { open: boolean } & React.HTMLAttributes<Dialog>) { export default function LoginDialog({ ...props }: { open: boolean } & React.HTMLAttributes<Dialog>) {
const shared = React.useContext(MainSharedContext) const shared = React.useContext(MainSharedContext)
const loginDialogRef = React.useRef<Dialog>(null)
const loginInputAccountRef = React.useRef<TextField>(null) const loginInputAccountRef = React.useRef<TextField>(null)
const loginInputPasswordRef = React.useRef<TextField>(null) const loginInputPasswordRef = React.useRef<TextField>(null)
return ( return (
<mdui-dialog {...props} headline="登录" ref={loginDialogRef}> <mdui-dialog {...props} headline="登录">
<mdui-text-field label="用户 ID / 用户名" ref={loginInputAccountRef}></mdui-text-field> <mdui-text-field label="用户 ID / 用户名" ref={loginInputAccountRef}></mdui-text-field>
<div style={{ <div style={{