@ws1298191366/cli 0.1.1 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +47 -14
- package/bin/jeecg.js +21 -10
- package/lib/auth.js +188 -67
- package/lib/page.js +459 -0
- package/lib/secret.js +19 -9
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @jeecg/cli
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
在浏览器里登录 JeecgBoot / 敲敲云 / 积木报表,给 AI skills 提供 token,不用再从 F12 复制 `X-Access-Token`。
|
|
4
4
|
|
|
5
5
|
需要 Node.js 18 以上,不依赖第三方包。
|
|
6
6
|
|
|
@@ -11,7 +11,9 @@ jeecg auth token --host http://127.0.0.1:8080/jeecg-boot --type jeecgboot
|
|
|
11
11
|
```
|
|
12
12
|
|
|
13
13
|
- 已登录:直接输出 token;快过期时自动用 refresh_token 换新的
|
|
14
|
-
- 没登录过,或者登录已过期 /
|
|
14
|
+
- 没登录过,或者登录已过期 / 被撤销:自动打开浏览器登录,完成后命令输出 token
|
|
15
|
+
- JeecgBoot / 敲敲云:CLI 在本机起一个登录页,输入账号、密码、验证码,确认租户后点「授权」
|
|
16
|
+
- 积木报表:打开积木自己的授权页,没登录时先跳到宿主系统的登录页,登录后点「授权」
|
|
15
17
|
- 第一次要给地址和产品标识,之后会记住,不用再带
|
|
16
18
|
- 提示信息都打到 stderr,stdout 只有 token,所以脚本里直接这样用:
|
|
17
19
|
|
|
@@ -19,7 +21,7 @@ jeecg auth token --host http://127.0.0.1:8080/jeecg-boot --type jeecgboot
|
|
|
19
21
|
python xxx.py --api-base <地址> --token "$(jeecg auth token)"
|
|
20
22
|
```
|
|
21
23
|
|
|
22
|
-
token 由 shell
|
|
24
|
+
token 由 shell 在执行时代入,不会出现在命令和对话记录里。自动登录时最多等 110 秒(AI 工具执行命令一般 2 分钟超时),超时会提示重新执行。
|
|
23
25
|
|
|
24
26
|
## 产品标识 --type
|
|
25
27
|
|
|
@@ -33,28 +35,56 @@ token 由 shell 在执行时代入,不会出现在命令和对话记录里。
|
|
|
33
35
|
|
|
34
36
|
- 不填时按 jeecgboot、jimureport 的顺序自动探测;敲敲云和 JeecgBoot 后端一样探测不出来,要明确写 `--type qiaoqiaoyun`
|
|
35
37
|
- 产品标识和登录信息存在一起,`jeecg auth status --json` 里的 `type` 字段就是它,skill 据此判断连的是哪个产品
|
|
36
|
-
- 授权页地址由后端返回:JeecgBoot / 敲敲云的授权页在它们自己的前端(后端配置 `jeecg.domainUrl.pc`),积木的授权页在积木自己的页面
|
|
37
38
|
|
|
38
39
|
## 其他命令
|
|
39
40
|
|
|
40
41
|
```bash
|
|
41
|
-
jeecg auth login [--host <地址>] [--type <产品>] # 只登录(最多等 5 分钟),不输出 token
|
|
42
|
-
jeecg auth complete "<浏览器地址栏里的地址>" # 打不开浏览器时完成登录
|
|
42
|
+
jeecg auth login [--host <地址>] [--type <产品>] # 只登录(最多等 5 分钟),不输出 token;已登录时直接确认租户,页面上可以切换账号
|
|
43
43
|
jeecg auth status --json # 本机登录状态:地址、产品、用户、租户(不输出 token)
|
|
44
44
|
jeecg auth logout [--host <地址>] # 撤销授权并删除本机凭证
|
|
45
|
+
jeecg auth complete "<浏览器地址栏里的地址>" # 积木报表打不开浏览器时完成登录
|
|
45
46
|
```
|
|
46
47
|
|
|
47
|
-
##
|
|
48
|
+
## 登录页(JeecgBoot / 敲敲云)
|
|
48
49
|
|
|
49
|
-
-
|
|
50
|
-
-
|
|
50
|
+
- 页面由 CLI 在本机 `127.0.0.1` 随机端口提供,不依赖 vue3 前端,后端也不用配置前端地址
|
|
51
|
+
- 验证码用原来的 `/sys/randomImage/{key}`,要不要验证码跟系统的 `jeecg.firewall.enableLoginCaptcha` 开关走;登录失败次数和网页登录共用(失败 5 次锁 10 分钟)
|
|
52
|
+
- 签发的是 CLI 专用的 token,不会把浏览器里的登录顶掉,开了「禁止多地登录」也一样
|
|
53
|
+
- 租户只记在本机(`jeecg auth status` 能看到),skill 调接口时用它做请求头 `X-Tenant-Id`;换租户再执行一次 `jeecg auth login`
|
|
54
|
+
- 地址里带随机串,只接受页面自己发的请求,别的网页操作不了;账号密码只在本机和后端之间传
|
|
55
|
+
|
|
56
|
+
## 打不开浏览器时(SSH 登录的服务器、没有图形界面的 Linux)
|
|
57
|
+
|
|
58
|
+
- JeecgBoot / 敲敲云:执行 `jeecg auth login`,它会打印登录页地址,在你自己电脑的浏览器里打开
|
|
59
|
+
- VS Code、Cursor 等远程连接会自动转发端口,直接点开链接即可
|
|
60
|
+
- 普通 SSH:先在自己电脑上执行 `ssh -L <端口>:127.0.0.1:<端口> <用户>@<服务器>`,再打开链接
|
|
61
|
+
- 被 AI 调用时,`jeecg auth token` 直接打印提示并退出(退出码 2):AI 改为在后台执行 `jeecg auth login`,把登录页地址发给用户
|
|
62
|
+
- 积木报表:命令打印授权链接,在自己电脑的浏览器里打开并点「授权」;浏览器跳回的 `127.0.0.1` 地址打不开,把地址栏里的完整地址粘贴回终端即可。被 AI 调用时命令打印链接后退出(退出码 2),用户授权后把地址栏的地址交给 `jeecg auth complete "<地址>"`(5 分钟内有效);也可以加 `--manual` 主动这样做
|
|
51
63
|
- 本机有浏览器但不想自动打开:加 `--no-open`
|
|
52
64
|
|
|
65
|
+
## Windows PowerShell
|
|
66
|
+
|
|
67
|
+
PowerShell 里执行 `jeecg` 报「无法加载文件 ...\jeecg.ps1,因为在此系统上禁止运行脚本」时,执行一次:
|
|
68
|
+
|
|
69
|
+
```powershell
|
|
70
|
+
Set-ExecutionPolicy -Scope CurrentUser RemoteSigned
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
只对当前用户生效,npm 全局装的 pnpm、yarn 等命令也需要这一步。不想改的话,用 `jeecg.cmd` 代替 `jeecg`。Git Bash、cmd 和 Python 的 `subprocess(..., shell=True)` 不受影响。
|
|
74
|
+
|
|
75
|
+
## macOS / Linux / WSL
|
|
76
|
+
|
|
77
|
+
- 安装报 `EACCES`:Node 装在系统目录时(Linux 用 apt 装的、macOS 用官网安装包装的)全局安装要权限,用 `sudo npm i -g @jeecg/cli`,或者用 nvm 装 Node(不用 sudo)
|
|
78
|
+
- Node 版本:Linux 发行版自带的 Node 常常低于 18(例如 Ubuntu 22.04 的 apt 装的是 12),`jeecg` 会直接提示升级
|
|
79
|
+
- 打开浏览器:macOS 用 `open`,Linux 桌面用 `xdg-open`;没装 xdg-utils 或打不开时会提示,复制链接到浏览器打开即可
|
|
80
|
+
- WSL:自动打开 Windows 上的默认浏览器,WSL2 默认把 Windows 的 `127.0.0.1` 端口转发到 WSL,登录页能直接打开;关掉了端口转发(`.wslconfig` 里 `localhostForwarding=false`)时打不开,要把转发打开
|
|
81
|
+
- SSH 到 Mac / Linux 上执行:按上面「打不开浏览器时」处理
|
|
82
|
+
- refresh_token:Mac 存钥匙串,Linux 桌面存密钥环;Mac 钥匙串锁着(例如 SSH 登录)、Linux 没有密钥环(服务器、WSL、Docker 常见)时退回明文,文件权限 600
|
|
83
|
+
|
|
53
84
|
## 服务器上的地址
|
|
54
85
|
|
|
55
|
-
- `--host` 填前端调接口用的后端地址(nginx 转发后的外网地址,例如 `https://boot.example.com/jeecgboot
|
|
56
|
-
-
|
|
57
|
-
- 授权后浏览器跳回的是你电脑上的 `127.0.0.1`,服务器不需要访问你的电脑,也不用开端口
|
|
86
|
+
- `--host` 填前端调接口用的后端地址(nginx 转发后的外网地址,例如 `https://boot.example.com/jeecgboot`),登录、换 token 都走这个地址
|
|
87
|
+
- 积木的授权页在积木自己的地址下,CLI 直接用 `--host` 拼,经过反向代理改了路径也没问题
|
|
58
88
|
- 多个环境各登录一次,用 `--host` 切换,最后登录的是默认地址
|
|
59
89
|
|
|
60
90
|
## 积木报表的宿主要求
|
|
@@ -63,8 +93,11 @@ jeecg auth logout [--host <地址>] # 撤销授权并删除本
|
|
|
63
93
|
|
|
64
94
|
## 凭证
|
|
65
95
|
|
|
66
|
-
存在 `~/.jeecg/credentials.json`,按地址区分,文件权限 600。refresh_token(30 天有效、能换出新 token)加密保存:Windows 用系统自带的 DPAPI(密文和当前 Windows 账号绑定),macOS
|
|
96
|
+
存在 `~/.jeecg/credentials.json`,按地址区分,文件权限 600。refresh_token(30 天有效、能换出新 token)加密保存:Windows 用系统自带的 DPAPI(密文和当前 Windows 账号绑定),macOS 存系统钥匙串(钥匙串锁着时退回明文),Linux 有 `secret-tool` 就存系统密钥环,没有时退回明文并提示;存进去后会读回来比对,存失败不会悄悄丢掉 refresh_token;access_token 每次取 token 都要读,保持原样,取 token 不变慢。JeecgBoot / 敲敲云的 access_token 有效期和网页登录一样(84 小时),积木 2 小时;refresh_token 30 天,每次刷新换新的。
|
|
67
97
|
|
|
68
98
|
## 后端要求
|
|
69
99
|
|
|
70
|
-
JeecgBoot /
|
|
100
|
+
- JeecgBoot / 敲敲云:`/.well-known/oauth-authorization-server`、`/oauth2/token`(账号密码登录、refresh_token 续期)、`/oauth2/revoke`、`/oauth2/userinfo`,验证码用原来的 `/sys/randomImage/{key}`;不需要前端页面
|
|
101
|
+
- 积木报表:`/jmreport/` 下的授权配置、授权页、换 token、撤销接口
|
|
102
|
+
|
|
103
|
+
老版本会提示「没有开启在线授权」(LHZP-2457)。
|
package/bin/jeecg.js
CHANGED
|
@@ -1,17 +1,21 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
'use strict';
|
|
3
|
+
// 用到 Node 18 自带的 fetch。Linux 发行版自带的 Node 常常偏旧(例如 Ubuntu 22.04 用 apt 装的是 12),
|
|
4
|
+
// 不先拦住的话会报一串语法错误或 fetch is not defined,看不出原因
|
|
5
|
+
if (Number(process.versions.node.split('.')[0]) < 18) {
|
|
6
|
+
console.error('jeecg 需要 Node.js 18 及以上,当前是 ' + process.version + ',请升级后再试(可以用 nvm 或从 https://nodejs.org 安装)');
|
|
7
|
+
process.exit(1);
|
|
8
|
+
}
|
|
3
9
|
const auth = require('../lib/auth');
|
|
4
10
|
|
|
5
11
|
const USAGE = `jeecg 命令行工具
|
|
6
12
|
|
|
7
13
|
用法:
|
|
8
|
-
jeecg auth token [--host <地址>] [--type <产品>] [--
|
|
14
|
+
jeecg auth token [--host <地址>] [--type <产品>] [--no-open]
|
|
9
15
|
输出可用的 access_token,给脚本用:--token "$(jeecg auth token)"
|
|
10
|
-
|
|
11
|
-
jeecg auth login [--host <地址>] [--type <产品>] [--
|
|
12
|
-
只登录(最多等 5 分钟),不输出 token
|
|
13
|
-
jeecg auth complete "<浏览器地址栏里的地址>"
|
|
14
|
-
打不开浏览器时用:授权后浏览器会跳到一个打不开的 127.0.0.1 地址,把它交给这条命令完成登录
|
|
16
|
+
快过期自动刷新;没登录过或登录已失效时自动打开浏览器登录,完成后输出(提示信息打到 stderr,不影响取值)
|
|
17
|
+
jeecg auth login [--host <地址>] [--type <产品>] [--no-open]
|
|
18
|
+
只登录(最多等 5 分钟),不输出 token;已经登录过时直接确认租户,也可以在页面上切换账号
|
|
15
19
|
jeecg auth status [--host <地址>] [--json]
|
|
16
20
|
查看本机登录状态:地址、产品、用户、租户(不输出 token)
|
|
17
21
|
jeecg auth logout [--host <地址>]
|
|
@@ -23,11 +27,18 @@ const USAGE = `jeecg 命令行工具
|
|
|
23
27
|
jimureport 积木报表,地址填应用地址,例如 http://127.0.0.1:8085(带不带 /jmreport 都行)
|
|
24
28
|
不填时按 jeecgboot、jimureport 的顺序自动探测;敲敲云要明确写 --type qiaoqiaoyun
|
|
25
29
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
30
|
+
登录页:
|
|
31
|
+
JeecgBoot / 敲敲云:CLI 在本机打开登录页,输入账号、密码、验证码,确认租户后点「授权」
|
|
32
|
+
积木报表:打开积木自己的授权页,没登录时先跳到宿主系统的登录页
|
|
33
|
+
|
|
34
|
+
打不开浏览器时(SSH 登录的服务器):
|
|
35
|
+
JeecgBoot / 敲敲云:在自己电脑的浏览器里打开命令打印的登录页地址;VS Code、Cursor 远程会自动转发端口,
|
|
36
|
+
普通 SSH 先执行 ssh -L <端口>:127.0.0.1:<端口> <用户>@<服务器>;
|
|
37
|
+
被 AI 调用时 jeecg auth token 直接提示后退出(退出码 2),改为在后台执行 jeecg auth login 等用户登录
|
|
38
|
+
积木报表:在自己电脑上打开授权链接,授权后把浏览器地址栏的地址粘贴回终端;被 AI 调用时命令打印链接后退出(退出码 2),
|
|
39
|
+
用户授权后执行 jeecg auth complete "<地址>";也可以加 --manual 主动这样做`;
|
|
29
40
|
|
|
30
|
-
const VALUE_OPTIONS = ['host', '
|
|
41
|
+
const VALUE_OPTIONS = ['host', 'type'];
|
|
31
42
|
|
|
32
43
|
function parseArgs(argv) {
|
|
33
44
|
const args = { _: [] };
|
package/lib/auth.js
CHANGED
|
@@ -2,36 +2,42 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* jeecg auth login / token / complete / status / logout
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
* 1
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
5
|
+
* 登录方式按产品分两种:
|
|
6
|
+
* - JeecgBoot / 敲敲云:CLI 在本机 127.0.0.1 起登录页(见 page.js),浏览器里登录、确认租户,
|
|
7
|
+
* CLI 用账号密码向后端 /oauth2/token 换 token;不依赖 vue3 前端,也不用配置前端地址
|
|
8
|
+
* - 积木报表:独立部署的积木没有统一的登录接口(登录由宿主系统负责),走积木自己的授权页(OAuth2 授权码 + PKCE):
|
|
9
|
+
* 本机起回调服务 → 打开积木授权页(没登录先跳宿主登录页)→ 点「授权」后浏览器带着授权码跳回 → 用授权码 + code_verifier 换 token
|
|
10
10
|
*
|
|
11
|
-
*
|
|
12
|
-
* 被 AI 调用(不在终端里)时打印链接后退出,用户授权后把地址栏的地址交给 jeecg auth complete
|
|
11
|
+
* token 存到 ~/.jeecg/credentials.json(refresh_token 加密,见 secret.js),快过期时用 refresh_token 自动续期
|
|
13
12
|
*
|
|
14
13
|
* 输出约定:stdout 只放给程序用的结果(token、status --json),提示信息一律打到 stderr,
|
|
15
14
|
* 这样 --token "$(jeecg auth token)" 里就算触发了登录,拿到的也只有 token
|
|
16
15
|
*/
|
|
17
16
|
const crypto = require('crypto');
|
|
17
|
+
const fs = require('fs');
|
|
18
18
|
const http = require('http');
|
|
19
19
|
const readline = require('readline');
|
|
20
20
|
const { spawn } = require('child_process');
|
|
21
21
|
const store = require('./store');
|
|
22
22
|
const secret = require('./secret');
|
|
23
|
+
const { pageLogin } = require('./page');
|
|
23
24
|
|
|
24
|
-
/** 后端内置的公共客户端,没有 secret
|
|
25
|
+
/** 后端内置的公共客户端,没有 secret */
|
|
25
26
|
const CLIENT_ID = 'jeecg-cli';
|
|
26
|
-
/** 手动执行 jeecg auth login
|
|
27
|
+
/** 手动执行 jeecg auth login 时,最多等 5 分钟 */
|
|
27
28
|
const LOGIN_TIMEOUT_MS = 5 * 60 * 1000;
|
|
28
29
|
/** jeecg auth token 自动登录时只等 110 秒:AI 工具执行命令默认 2 分钟超时,先自己超时退出、给出提示,别被直接杀掉 */
|
|
29
30
|
const AUTO_LOGIN_TIMEOUT_MS = 110 * 1000;
|
|
30
31
|
/** access_token 还剩 5 分钟就提前刷新 */
|
|
31
32
|
const REFRESH_AHEAD_MS = 5 * 60 * 1000;
|
|
32
|
-
/**
|
|
33
|
+
/** 被 AI 调用又打不开浏览器时的退出码:这次没拿到 token,按命令打印的提示让用户在自己电脑上完成登录 */
|
|
33
34
|
const MANUAL_EXIT_CODE = 2;
|
|
34
35
|
const HOST_EXAMPLE = 'JeecgBoot / 敲敲云 http://127.0.0.1:8080/jeecg-boot,积木报表 http://127.0.0.1:8085';
|
|
36
|
+
/** 打不开浏览器时(SSH 登录的服务器),告诉用户怎么在自己电脑上打开本机登录页;{port} 换成登录页的端口 */
|
|
37
|
+
const REMOTE_HINT =
|
|
38
|
+
'这台机器打不开浏览器(例如 SSH 登录的服务器),请在你自己电脑的浏览器里打开上面的地址:\n' +
|
|
39
|
+
'- VS Code、Cursor 等远程连接会自动转发端口,直接点开链接即可\n' +
|
|
40
|
+
'- 普通 SSH:先在自己电脑上执行 ssh -L {port}:127.0.0.1:{port} <用户>@<服务器>,再打开链接';
|
|
35
41
|
|
|
36
42
|
/**
|
|
37
43
|
* 产品标识(--type),决定请求后端哪组接口:
|
|
@@ -49,7 +55,7 @@ const DETECT_ORDER = ['jeecgboot', 'jimureport'];
|
|
|
49
55
|
|
|
50
56
|
async function login(args) {
|
|
51
57
|
const { host, type } = await resolveTarget(args, true);
|
|
52
|
-
const cred = await browserLogin(host, type, args, LOGIN_TIMEOUT_MS);
|
|
58
|
+
const cred = await browserLogin(host, type, args, LOGIN_TIMEOUT_MS, true);
|
|
53
59
|
if (cred) {
|
|
54
60
|
console.error(successText(cred));
|
|
55
61
|
}
|
|
@@ -59,7 +65,7 @@ async function login(args) {
|
|
|
59
65
|
* 输出可用的 access_token(给脚本用:--token "$(jeecg auth token)"),一条命令把取 token 的几种情况都处理掉:
|
|
60
66
|
* 1. 已登录且没过期:直接输出
|
|
61
67
|
* 2. 快过期:用 refresh_token 换新的
|
|
62
|
-
* 3. 没登录过,或者登录已过期 /
|
|
68
|
+
* 3. 没登录过,或者登录已过期 / 被撤销:自动打开浏览器登录,完成后输出
|
|
63
69
|
*/
|
|
64
70
|
async function token(args) {
|
|
65
71
|
const { host, type } = await resolveTarget(args, true);
|
|
@@ -73,10 +79,10 @@ async function token(args) {
|
|
|
73
79
|
process.stdout.write(refreshed.accessToken + '\n');
|
|
74
80
|
return;
|
|
75
81
|
}
|
|
76
|
-
console.error(cred ? `登录已过期或已被撤销(${host}
|
|
77
|
-
const fresh = await browserLogin(host, type || (cred && cred.type), args, AUTO_LOGIN_TIMEOUT_MS);
|
|
82
|
+
console.error(cred ? `登录已过期或已被撤销(${host}),重新登录:` : `还没有登录 ${host},开始登录:`);
|
|
83
|
+
const fresh = await browserLogin(host, type || (cred && cred.type), args, AUTO_LOGIN_TIMEOUT_MS, false);
|
|
78
84
|
if (!fresh) {
|
|
79
|
-
//
|
|
85
|
+
// 打不开浏览器:已经打印了怎么在用户自己电脑上登录,这次不输出 token
|
|
80
86
|
return;
|
|
81
87
|
}
|
|
82
88
|
console.error(successText(fresh));
|
|
@@ -84,7 +90,7 @@ async function token(args) {
|
|
|
84
90
|
}
|
|
85
91
|
|
|
86
92
|
/**
|
|
87
|
-
*
|
|
93
|
+
* 用浏览器地址栏里的回调地址完成积木的授权:配合 jeecg auth login --manual,或者打不开浏览器时自动打印的链接
|
|
88
94
|
*/
|
|
89
95
|
async function complete(args) {
|
|
90
96
|
const callbackUrl = args._[2];
|
|
@@ -114,6 +120,7 @@ async function status(args) {
|
|
|
114
120
|
type: typeOf(data.hosts[h]),
|
|
115
121
|
username: data.hosts[h].username,
|
|
116
122
|
tenant_id: data.hosts[h].tenantId,
|
|
123
|
+
tenant_name: data.hosts[h].tenantName || null,
|
|
117
124
|
default: h === data.defaultHost,
|
|
118
125
|
}));
|
|
119
126
|
if (args.json) {
|
|
@@ -126,7 +133,7 @@ async function status(args) {
|
|
|
126
133
|
return;
|
|
127
134
|
}
|
|
128
135
|
for (const c of list) {
|
|
129
|
-
console.log(`${c.default ? '* ' : ' '}${c.host} ${PRODUCTS[c.type].name} 用户 ${c.username}${c.tenant_id
|
|
136
|
+
console.log(`${c.default ? '* ' : ' '}${c.host} ${PRODUCTS[c.type].name} 用户 ${c.username}${tenantText(c.tenant_id, c.tenant_name, ' 租户 ')}`);
|
|
130
137
|
}
|
|
131
138
|
}
|
|
132
139
|
|
|
@@ -141,11 +148,7 @@ async function logout(args) {
|
|
|
141
148
|
const refreshToken = secret.unprotect(host, cred.refreshToken);
|
|
142
149
|
if (refreshToken) {
|
|
143
150
|
try {
|
|
144
|
-
await
|
|
145
|
-
method: 'POST',
|
|
146
|
-
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
147
|
-
body: new URLSearchParams({ token: refreshToken, client_id: CLIENT_ID }),
|
|
148
|
-
});
|
|
151
|
+
await revokeToken(cred.revocationEndpoint, refreshToken);
|
|
149
152
|
} catch (e) {
|
|
150
153
|
console.log(`服务器撤销失败(${e.message}),只删除了本机凭证`);
|
|
151
154
|
}
|
|
@@ -160,13 +163,16 @@ async function logout(args) {
|
|
|
160
163
|
}
|
|
161
164
|
|
|
162
165
|
/**
|
|
163
|
-
*
|
|
166
|
+
* 浏览器登录,成功后保存凭证并设为默认地址;被 AI 调用又打不开浏览器时返回 null(已打印怎么在用户自己电脑上完成)
|
|
167
|
+
*
|
|
168
|
+
* @param explicitLogin 是不是 jeecg auth login:已经登录过时登录页直接显示确认租户(用来换租户);
|
|
169
|
+
* 打不开浏览器时也起登录页等用户。jeecg auth token 自动登录时为 false
|
|
164
170
|
*/
|
|
165
|
-
async function browserLogin(host, type, args, timeoutMs) {
|
|
171
|
+
async function browserLogin(host, type, args, timeoutMs, explicitLogin) {
|
|
166
172
|
// 1. 按产品标识请求对应的授权配置;没给标识时按 DETECT_ORDER 依次探测
|
|
167
173
|
let meta = null;
|
|
168
174
|
for (const t of type ? [type] : DETECT_ORDER) {
|
|
169
|
-
meta = await fetchMetadata(host,
|
|
175
|
+
meta = await fetchMetadata(host, t);
|
|
170
176
|
if (meta) {
|
|
171
177
|
type = t;
|
|
172
178
|
break;
|
|
@@ -178,39 +184,117 @@ async function browserLogin(host, type, args, timeoutMs) {
|
|
|
178
184
|
// 换 token、撤销直接用用户给的地址拼:nginx 常把外网 /jeecgboot 转发到后端 /jeecg-boot,
|
|
179
185
|
// 后端按请求算出来的 token_endpoint(.../jeecg-boot/oauth2/token)在外网访问不到
|
|
180
186
|
const base = host + PRODUCTS[type].prefix;
|
|
187
|
+
const endpoints = { tokenEndpoint: `${base}/oauth2/token`, revocationEndpoint: `${base}/oauth2/revoke` };
|
|
188
|
+
return type === 'jimureport' ? oauthLogin(host, type, endpoints, args, timeoutMs) : jeecgLogin(host, type, meta, endpoints, args, timeoutMs, explicitLogin);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* JeecgBoot / 敲敲云:本机登录页(page.js),登录、确认租户后保存凭证
|
|
193
|
+
*/
|
|
194
|
+
async function jeecgLogin(host, type, meta, endpoints, args, timeoutMs, explicitLogin) {
|
|
195
|
+
// 被 AI 调用(不在终端里)又打不开浏览器(例如 SSH 登录的服务器):AI 的命令执行完才看得到输出,
|
|
196
|
+
// 登录页地址等到超时也转不到用户手里,直接说明怎么做并退出
|
|
197
|
+
if (!explicitLogin && !process.stdin.isTTY && !hasLocalBrowser()) {
|
|
198
|
+
console.error(
|
|
199
|
+
`这台机器打不开浏览器(例如 SSH 登录的服务器),没法自动登录。请在后台执行:\n jeecg auth login --host ${host} --type ${type}\n` +
|
|
200
|
+
'它会打印登录页地址并等待 5 分钟:把地址发给用户,在用户自己电脑的浏览器里打开(VS Code、Cursor 远程会自动转发端口;' +
|
|
201
|
+
'普通 SSH 先在自己电脑上执行 ssh -L <端口>:127.0.0.1:<端口> <用户>@<服务器>),登录并点「授权」后,再重新执行刚才的命令'
|
|
202
|
+
);
|
|
203
|
+
process.exitCode = MANUAL_EXIT_CODE;
|
|
204
|
+
return null;
|
|
205
|
+
}
|
|
206
|
+
const old = store.load().hosts[host];
|
|
207
|
+
// 已经登录过:用已有的 token 查当前账号,登录页直接显示确认租户(页面上可以点「切换账号」重新登录)
|
|
208
|
+
const user = explicitLogin && old ? await currentUser(host, old) : null;
|
|
209
|
+
// 默认选中本机上次选的租户(后端返回的默认租户是网页上次登录的租户)
|
|
210
|
+
if (user && old.tenantId != null && (user.tenants || []).some((t) => t.id === old.tenantId)) {
|
|
211
|
+
user.tenant_id = old.tenantId;
|
|
212
|
+
}
|
|
213
|
+
const result = await pageLogin({
|
|
214
|
+
host,
|
|
215
|
+
productName: PRODUCTS[type].name,
|
|
216
|
+
captcha: meta.login_captcha !== false,
|
|
217
|
+
tokenEndpoint: endpoints.tokenEndpoint,
|
|
218
|
+
revocationEndpoint: endpoints.revocationEndpoint,
|
|
219
|
+
user,
|
|
220
|
+
openBrowser: args.open !== false && hasLocalBrowser(),
|
|
221
|
+
open: openBrowser,
|
|
222
|
+
remoteHint: hasLocalBrowser() ? '' : REMOTE_HINT,
|
|
223
|
+
timeoutMs,
|
|
224
|
+
});
|
|
225
|
+
const tenants = result.token ? result.token.tenants : user.tenants;
|
|
226
|
+
const tenant = (tenants || []).find((t) => t.id === result.tenantId);
|
|
227
|
+
const data = store.load();
|
|
228
|
+
if (result.token) {
|
|
229
|
+
// 重新登录了:之前的 refresh_token 用不上了,撤销掉(要在保存新凭证之前取出来,钥匙串里同一个地址只存一份)
|
|
230
|
+
const oldRefreshToken = old ? secret.unprotect(host, old.refreshToken) : null;
|
|
231
|
+
data.hosts[host] = toCredential(result.token, host, type, endpoints.tokenEndpoint, endpoints.revocationEndpoint, result.tenantId, tenant && tenant.name);
|
|
232
|
+
if (oldRefreshToken) {
|
|
233
|
+
revokeToken(old.revocationEndpoint, oldRefreshToken).catch(() => {});
|
|
234
|
+
}
|
|
235
|
+
} else {
|
|
236
|
+
// 沿用已有的登录,只换了租户
|
|
237
|
+
data.hosts[host] = { ...old, type, tenantId: result.tenantId, tenantName: tenant ? tenant.name : null };
|
|
238
|
+
}
|
|
239
|
+
data.defaultHost = host;
|
|
240
|
+
store.save(data);
|
|
241
|
+
return data.hosts[host];
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* CLI 已有的 token 还能用时,查当前账号和可选租户;过期、被撤销或者后端没有这个接口时返回 null(登录页显示登录表单)
|
|
246
|
+
*/
|
|
247
|
+
async function currentUser(host, cred) {
|
|
248
|
+
if (Date.now() >= cred.expiresAt) {
|
|
249
|
+
return null;
|
|
250
|
+
}
|
|
251
|
+
try {
|
|
252
|
+
const res = await fetch(`${host}/oauth2/userinfo`, { headers: { 'X-Access-Token': cred.accessToken } });
|
|
253
|
+
const body = await res.json().catch(() => ({}));
|
|
254
|
+
return res.ok && body.success && body.result ? body.result : null;
|
|
255
|
+
} catch (e) {
|
|
256
|
+
return null;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* 积木报表:积木自己的授权页(授权码 + PKCE)
|
|
262
|
+
*/
|
|
263
|
+
async function oauthLogin(host, type, endpoints, args, timeoutMs) {
|
|
181
264
|
const login = {
|
|
182
265
|
host,
|
|
183
266
|
type,
|
|
184
267
|
verifier: crypto.randomBytes(32).toString('base64url'),
|
|
185
268
|
state: crypto.randomBytes(16).toString('base64url'),
|
|
186
|
-
|
|
187
|
-
|
|
269
|
+
...endpoints,
|
|
270
|
+
// 授权页在积木自己的地址下,直接用 --host 拼(反向代理改了路径时,后端按请求算出来的地址外网访问不到)
|
|
271
|
+
authorizeEndpoint: `${host}${PRODUCTS.jimureport.prefix}/oauth2/authorize`,
|
|
188
272
|
};
|
|
189
|
-
warnLocalAuthorizePage(meta.authorization_endpoint, host);
|
|
190
273
|
|
|
191
|
-
//
|
|
274
|
+
// 打不开本机浏览器又不在终端里(例如 AI 在服务器上调用):打印链接后退出,等 jeecg auth complete
|
|
192
275
|
if (args.manual || (!process.stdin.isTTY && !hasLocalBrowser())) {
|
|
193
276
|
login.redirectUri = `http://127.0.0.1:${await freePort()}/callback`;
|
|
194
277
|
login.expiresAt = Date.now() + LOGIN_TIMEOUT_MS;
|
|
195
278
|
store.savePending(login);
|
|
196
|
-
console.error(`请在自己电脑的浏览器里打开下面的链接,确认授权(${PRODUCTS[type].name},5 分钟内有效):\n${authorizeUrl(
|
|
279
|
+
console.error(`请在自己电脑的浏览器里打开下面的链接,确认授权(${PRODUCTS[type].name},5 分钟内有效):\n${authorizeUrl(login)}\n`);
|
|
197
280
|
console.error('授权后浏览器会跳到一个打不开的 127.0.0.1 地址,把地址栏里的完整地址交给:jeecg auth complete "<地址>"');
|
|
198
281
|
process.exitCode = MANUAL_EXIT_CODE;
|
|
199
282
|
return null;
|
|
200
283
|
}
|
|
201
284
|
|
|
202
|
-
//
|
|
285
|
+
// 本机起回调服务,打开授权页
|
|
203
286
|
const callback = await startCallbackServer(login.state);
|
|
204
287
|
login.redirectUri = `http://127.0.0.1:${callback.port}/callback`;
|
|
205
|
-
console.error(`请在浏览器中确认授权(${PRODUCTS[type].name}):\n${authorizeUrl(
|
|
288
|
+
console.error(`请在浏览器中确认授权(${PRODUCTS[type].name}):\n${authorizeUrl(login)}\n`);
|
|
206
289
|
const browserOpened = args.open !== false && hasLocalBrowser();
|
|
207
290
|
if (browserOpened) {
|
|
208
|
-
openBrowser(authorizeUrl(
|
|
291
|
+
openBrowser(authorizeUrl(login));
|
|
209
292
|
}
|
|
210
|
-
// 在终端里又没自动打开浏览器(--no-open、SSH
|
|
211
|
-
|
|
293
|
+
// 在终端里又没自动打开浏览器(--no-open、SSH):浏览器跳回时可能打不开,也可以把地址栏的地址粘贴回来;
|
|
294
|
+
// WSL 关掉了端口转发(.wslconfig 里 localhostForwarding=false)时,Windows 浏览器跳回的地址也打不开,同样允许粘贴
|
|
295
|
+
const paste = (!browserOpened || isWsl()) && process.stdin.isTTY ? waitForPaste(login.state) : null;
|
|
212
296
|
|
|
213
|
-
//
|
|
297
|
+
// 等用户点「授权」,用授权码换 token
|
|
214
298
|
let code;
|
|
215
299
|
try {
|
|
216
300
|
code = await (paste ? Promise.race([callback.waitForCode(timeoutMs), paste.promise]) : callback.waitForCode(timeoutMs));
|
|
@@ -224,7 +308,7 @@ async function browserLogin(host, type, args, timeoutMs) {
|
|
|
224
308
|
}
|
|
225
309
|
|
|
226
310
|
/**
|
|
227
|
-
*
|
|
311
|
+
* 积木:用授权码 + code_verifier 换 token,保存凭证并设为默认地址
|
|
228
312
|
*/
|
|
229
313
|
async function exchangeCode(login, code) {
|
|
230
314
|
const t = await requestToken(login.tokenEndpoint, {
|
|
@@ -235,14 +319,14 @@ async function exchangeCode(login, code) {
|
|
|
235
319
|
client_id: CLIENT_ID,
|
|
236
320
|
});
|
|
237
321
|
const data = store.load();
|
|
238
|
-
data.hosts[login.host] = toCredential(t, login.host, login.type, login.tokenEndpoint, login.revocationEndpoint);
|
|
322
|
+
data.hosts[login.host] = toCredential(t, login.host, login.type, login.tokenEndpoint, login.revocationEndpoint, null, null);
|
|
239
323
|
data.defaultHost = login.host;
|
|
240
324
|
store.save(data);
|
|
241
325
|
return data.hosts[login.host];
|
|
242
326
|
}
|
|
243
327
|
|
|
244
328
|
/**
|
|
245
|
-
* 用 refresh_token 换新 token;登录已过期、被撤销或 refresh_token 解不开时返回 null
|
|
329
|
+
* 用 refresh_token 换新 token;登录已过期、被撤销或 refresh_token 解不开时返回 null(由调用方重新登录)
|
|
246
330
|
*/
|
|
247
331
|
async function refresh(host, cred) {
|
|
248
332
|
const refreshToken = secret.unprotect(host, cred.refreshToken);
|
|
@@ -252,7 +336,8 @@ async function refresh(host, cred) {
|
|
|
252
336
|
try {
|
|
253
337
|
const t = await requestToken(cred.tokenEndpoint, { grant_type: 'refresh_token', refresh_token: refreshToken, client_id: CLIENT_ID });
|
|
254
338
|
const data = store.load();
|
|
255
|
-
|
|
339
|
+
// 租户是登录页上选的,只记在本机,刷新时沿用
|
|
340
|
+
data.hosts[host] = toCredential(t, host, typeOf(cred), cred.tokenEndpoint, cred.revocationEndpoint, cred.tenantId, cred.tenantName);
|
|
256
341
|
store.save(data);
|
|
257
342
|
return data.hosts[host];
|
|
258
343
|
} catch (e) {
|
|
@@ -305,21 +390,26 @@ function normalizeHost(host) {
|
|
|
305
390
|
}
|
|
306
391
|
|
|
307
392
|
/**
|
|
308
|
-
* 读授权配置;连不上直接报错,接口不存在(老版本、不是这个产品、宿主没开启)返回 null
|
|
393
|
+
* 读授权配置;连不上直接报错,接口不存在(老版本、不是这个产品、宿主没开启)返回 null。
|
|
394
|
+
* 积木要有授权页(authorization_endpoint),JeecgBoot / 敲敲云要支持账号密码登录(grant_types_supported 里有 password)
|
|
309
395
|
*/
|
|
310
|
-
async function fetchMetadata(host,
|
|
396
|
+
async function fetchMetadata(host, type) {
|
|
311
397
|
let res;
|
|
312
398
|
try {
|
|
313
|
-
res = await fetch(`${host}${prefix}/.well-known/oauth-authorization-server`);
|
|
399
|
+
res = await fetch(`${host}${PRODUCTS[type].prefix}/.well-known/oauth-authorization-server`);
|
|
314
400
|
} catch (e) {
|
|
315
401
|
throw new Error(`连不上 ${host}:${(e.cause && e.cause.code) || e.message}`);
|
|
316
402
|
}
|
|
317
403
|
// 可能是 404,也可能被 Shiro 拦成 401(Token失效),前端开发服务器还会返回 index.html
|
|
318
404
|
const meta = await res.json().catch(() => ({}));
|
|
319
|
-
|
|
405
|
+
if (!res.ok || !meta.token_endpoint) {
|
|
406
|
+
return null;
|
|
407
|
+
}
|
|
408
|
+
const supported = type === 'jimureport' ? Boolean(meta.authorization_endpoint) : (meta.grant_types_supported || []).includes('password');
|
|
409
|
+
return supported ? meta : null;
|
|
320
410
|
}
|
|
321
411
|
|
|
322
|
-
function authorizeUrl(
|
|
412
|
+
function authorizeUrl(login) {
|
|
323
413
|
const params = new URLSearchParams({
|
|
324
414
|
response_type: 'code',
|
|
325
415
|
client_id: CLIENT_ID,
|
|
@@ -328,10 +418,7 @@ function authorizeUrl(meta, login, scope) {
|
|
|
328
418
|
code_challenge_method: 'S256',
|
|
329
419
|
state: login.state,
|
|
330
420
|
});
|
|
331
|
-
|
|
332
|
-
params.set('scope', scope);
|
|
333
|
-
}
|
|
334
|
-
return `${meta.authorization_endpoint}?${params}`;
|
|
421
|
+
return `${login.authorizeEndpoint}?${params}`;
|
|
335
422
|
}
|
|
336
423
|
|
|
337
424
|
/**
|
|
@@ -386,9 +473,31 @@ function hasLocalBrowser() {
|
|
|
386
473
|
if (process.env.SSH_CONNECTION || process.env.SSH_TTY) {
|
|
387
474
|
return false;
|
|
388
475
|
}
|
|
476
|
+
// WSL 里一般没有图形界面,但可以打开 Windows 上的浏览器
|
|
477
|
+
if (isWsl()) {
|
|
478
|
+
return true;
|
|
479
|
+
}
|
|
389
480
|
return !(process.platform === 'linux' && !process.env.DISPLAY && !process.env.WAYLAND_DISPLAY);
|
|
390
481
|
}
|
|
391
482
|
|
|
483
|
+
/**
|
|
484
|
+
* 是否在 WSL(Windows 里的 Linux)里:Windows 上用 Claude Code 的人常在 WSL 里跑。
|
|
485
|
+
* 浏览器在 Windows 那边,WSL2 默认把 Windows 的 127.0.0.1 端口转发到 WSL,浏览器打开本机地址时 CLI 能收到
|
|
486
|
+
*/
|
|
487
|
+
function isWsl() {
|
|
488
|
+
if (process.platform !== 'linux') {
|
|
489
|
+
return false;
|
|
490
|
+
}
|
|
491
|
+
if (process.env.WSL_DISTRO_NAME || process.env.WSL_INTEROP) {
|
|
492
|
+
return true;
|
|
493
|
+
}
|
|
494
|
+
try {
|
|
495
|
+
return /microsoft/i.test(fs.readFileSync('/proc/version', 'utf8'));
|
|
496
|
+
} catch (e) {
|
|
497
|
+
return false;
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
392
501
|
function freePort() {
|
|
393
502
|
return new Promise((resolve, reject) => {
|
|
394
503
|
const server = http.createServer();
|
|
@@ -408,7 +517,15 @@ function notEnabledText(host, type) {
|
|
|
408
517
|
}
|
|
409
518
|
|
|
410
519
|
function successText(cred) {
|
|
411
|
-
return `登录成功(${PRODUCTS[typeOf(cred)].name}):${cred.username}${cred.tenantId
|
|
520
|
+
return `登录成功(${PRODUCTS[typeOf(cred)].name}):${cred.username}${tenantText(cred.tenantId, cred.tenantName, ',租户 ')}`;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/** 租户显示成「十二只绵羊(1014)」,以前存的只有 id */
|
|
524
|
+
function tenantText(id, name, prefix) {
|
|
525
|
+
if (id == null) {
|
|
526
|
+
return '';
|
|
527
|
+
}
|
|
528
|
+
return prefix + (name ? `${name}(${id})` : id);
|
|
412
529
|
}
|
|
413
530
|
|
|
414
531
|
/** 以前存的凭证没有 type,都是 JeecgBoot 地址 */
|
|
@@ -416,18 +533,6 @@ function typeOf(cred) {
|
|
|
416
533
|
return PRODUCTS[cred.type] ? cred.type : 'jeecgboot';
|
|
417
534
|
}
|
|
418
535
|
|
|
419
|
-
/**
|
|
420
|
-
* 授权页地址来自后端配置 jeecg.domainUrl.pc,默认是 http://localhost:3100;部署到服务器没改的话,浏览器会打开本机地址
|
|
421
|
-
*/
|
|
422
|
-
function warnLocalAuthorizePage(authorizeEndpoint, host) {
|
|
423
|
-
const isLocal = (u) => /^https?:\/\/(localhost|127\.0\.0\.1)(:|\/|$)/i.test(u);
|
|
424
|
-
if (isLocal(authorizeEndpoint) && !isLocal(host)) {
|
|
425
|
-
const origin = new URL(authorizeEndpoint).origin;
|
|
426
|
-
console.error(`注意:后端返回的授权页是 ${origin},服务器上可能没配置 jeecg.domainUrl.pc(要配成前端的外网地址)。`);
|
|
427
|
-
console.error(`临时办法:把下面链接里的 ${origin} 换成前端地址,再在浏览器里打开。\n`);
|
|
428
|
-
}
|
|
429
|
-
}
|
|
430
|
-
|
|
431
536
|
async function requestToken(endpoint, form) {
|
|
432
537
|
const res = await fetch(endpoint, {
|
|
433
538
|
method: 'POST',
|
|
@@ -444,12 +549,21 @@ async function requestToken(endpoint, form) {
|
|
|
444
549
|
return body;
|
|
445
550
|
}
|
|
446
551
|
|
|
447
|
-
function
|
|
552
|
+
async function revokeToken(endpoint, refreshToken) {
|
|
553
|
+
await fetch(endpoint, {
|
|
554
|
+
method: 'POST',
|
|
555
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
556
|
+
body: new URLSearchParams({ token: refreshToken, client_id: CLIENT_ID }),
|
|
557
|
+
});
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
function toCredential(t, host, type, tokenEndpoint, revocationEndpoint, tenantId, tenantName) {
|
|
448
561
|
return {
|
|
449
562
|
type,
|
|
450
563
|
username: t.username,
|
|
451
|
-
|
|
452
|
-
|
|
564
|
+
// 登录页上选的租户:JeecgBoot / 敲敲云的租户由请求头 X-Tenant-Id 决定,不绑在 token 上,只记在本机给 skill 用
|
|
565
|
+
tenantId: tenantId == null ? null : tenantId,
|
|
566
|
+
tenantName: tenantName || null,
|
|
453
567
|
accessToken: t.access_token,
|
|
454
568
|
refreshToken: secret.protect(host, t.refresh_token),
|
|
455
569
|
expiresAt: Date.now() + Number(t.expires_in || 0) * 1000,
|
|
@@ -459,7 +573,7 @@ function toCredential(t, host, type, tokenEndpoint, revocationEndpoint) {
|
|
|
459
573
|
}
|
|
460
574
|
|
|
461
575
|
/**
|
|
462
|
-
*
|
|
576
|
+
* 积木的本机回调服务:只监听 127.0.0.1,state 对不上的请求直接忽略(不是这次登录发起的)
|
|
463
577
|
*/
|
|
464
578
|
function startCallbackServer(expectedState) {
|
|
465
579
|
let settle;
|
|
@@ -519,17 +633,24 @@ function sendPage(res, title, message) {
|
|
|
519
633
|
}
|
|
520
634
|
|
|
521
635
|
function openBrowser(url) {
|
|
522
|
-
// Windows 不走 cmd /c start:URL 里的 & 会被 cmd
|
|
636
|
+
// Windows 不走 cmd /c start:URL 里的 & 会被 cmd 当成命令分隔符截断;WSL 里调 Windows 的 rundll32.exe,打开 Windows 上的默认浏览器
|
|
523
637
|
const [cmd, cmdArgs] =
|
|
524
638
|
process.platform === 'win32'
|
|
525
639
|
? ['rundll32', ['url.dll,FileProtocolHandler', url]]
|
|
640
|
+
: isWsl()
|
|
641
|
+
? ['rundll32.exe', ['url.dll,FileProtocolHandler', url]]
|
|
526
642
|
: process.platform === 'darwin'
|
|
527
643
|
? ['open', [url]]
|
|
528
644
|
: ['xdg-open', [url]];
|
|
529
645
|
const tip = () => console.error('没能自动打开浏览器,请手动复制上面的链接到浏览器打开');
|
|
530
646
|
try {
|
|
531
647
|
const child = spawn(cmd, cmdArgs, { stdio: 'ignore', detached: true });
|
|
648
|
+
// 命令不存在(例如精简的 Linux 没装 xdg-utils)会触发 error
|
|
532
649
|
child.on('error', tip);
|
|
650
|
+
// open / xdg-open 找不到能打开链接的浏览器时不触发 error,只是退出码不为 0(xdg-open 是 3 或 4)
|
|
651
|
+
if (cmd === 'open' || cmd === 'xdg-open') {
|
|
652
|
+
child.on('exit', (code) => code && tip());
|
|
653
|
+
}
|
|
533
654
|
child.unref();
|
|
534
655
|
} catch (e) {
|
|
535
656
|
tip();
|
package/lib/page.js
ADDED
|
@@ -0,0 +1,459 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* JeecgBoot / 敲敲云的登录页:CLI 在本机 127.0.0.1 起一个页面,浏览器里登录、确认租户后拿到 token。
|
|
4
|
+
* 不依赖 vue3 前端,也不用配置前端地址;积木报表还是用积木自己的授权页(见 auth.js 的 oauthLogin)。
|
|
5
|
+
*
|
|
6
|
+
* 页面只和本机这个服务通信,由 CLI 转给后端:
|
|
7
|
+
* - 验证码图片:原来的 /sys/randomImage/{key}
|
|
8
|
+
* - 登录:/oauth2/token(grant_type=password),返回 token 和可选租户
|
|
9
|
+
* - CLI 已经登录过(再次执行 jeecg auth login):/oauth2/userinfo 取当前账号,直接显示确认租户,可以点「切换账号」重新登录
|
|
10
|
+
*
|
|
11
|
+
* 防止别的网页操作这个本机服务:地址里带随机串,猜不到;提交只接受同源的 JSON 请求
|
|
12
|
+
*/
|
|
13
|
+
const crypto = require('crypto');
|
|
14
|
+
const http = require('http');
|
|
15
|
+
|
|
16
|
+
const CLIENT_ID = 'jeecg-cli';
|
|
17
|
+
/** 请求体上限,登录表单用不了这么多 */
|
|
18
|
+
const MAX_BODY = 16 * 1024;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* 起登录页,等用户在浏览器里登录并确认租户
|
|
22
|
+
*
|
|
23
|
+
* @param {object} opts
|
|
24
|
+
* @param {string} opts.host 后端地址,例如 http://127.0.0.1:8080/jeecg-boot
|
|
25
|
+
* @param {string} opts.productName 产品名,显示在页面上,例如 JeecgBoot、敲敲云
|
|
26
|
+
* @param {boolean} opts.captcha 登录要不要验证码(后端授权配置里的 login_captcha)
|
|
27
|
+
* @param {string} opts.tokenEndpoint 换 token 的地址
|
|
28
|
+
* @param {string} opts.revocationEndpoint 撤销授权的地址
|
|
29
|
+
* @param {object|null} opts.user CLI 已登录时的当前账号(username、realname、tenant_id、tenants),没有时为 null
|
|
30
|
+
* @param {boolean} opts.openBrowser 是否自动打开浏览器
|
|
31
|
+
* @param {function} opts.open 打开浏览器的方法
|
|
32
|
+
* @param {string} opts.remoteHint 打不开浏览器时额外打印的提示(端口转发说明),没有时为空
|
|
33
|
+
* @param {number} opts.timeoutMs 最多等多久
|
|
34
|
+
* @returns {Promise<{token: object|null, tenantId: number|null}>} token 为 null 表示沿用已有的登录,只换了租户
|
|
35
|
+
*/
|
|
36
|
+
async function pageLogin(opts) {
|
|
37
|
+
const secret = crypto.randomBytes(16).toString('base64url');
|
|
38
|
+
const nonce = crypto.randomBytes(16).toString('base64');
|
|
39
|
+
// 登录成功、还没点「授权」的 token:点「取消」、超时或换账号重新登录时要撤销
|
|
40
|
+
let pending = null;
|
|
41
|
+
let settle;
|
|
42
|
+
const result = new Promise((resolve, reject) => {
|
|
43
|
+
settle = { resolve, reject };
|
|
44
|
+
});
|
|
45
|
+
result.catch(() => {});
|
|
46
|
+
|
|
47
|
+
const server = http.createServer((req, res) => {
|
|
48
|
+
handle(req, res).catch((e) => {
|
|
49
|
+
// 请求体太大时连接已经断开,回不了
|
|
50
|
+
if (!res.headersSent && !res.destroyed) {
|
|
51
|
+
sendJson(res, 500, { error: e.message });
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
async function handle(req, res) {
|
|
57
|
+
const url = new URL(req.url, 'http://127.0.0.1');
|
|
58
|
+
const base = `/${secret}`;
|
|
59
|
+
if (url.pathname !== base && !url.pathname.startsWith(base + '/')) {
|
|
60
|
+
res.writeHead(404, { Connection: 'close' });
|
|
61
|
+
res.end();
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
const path = url.pathname.slice(base.length) || '/';
|
|
65
|
+
if (req.method === 'GET' && path === '/') {
|
|
66
|
+
sendHtml(res, renderPage(nonce), nonce);
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
if (req.method === 'GET' && path === '/api/init') {
|
|
70
|
+
sendJson(res, 200, { product: opts.productName, host: opts.host, captcha: opts.captcha, user: opts.user ? pickUser(opts.user) : null });
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
if (req.method === 'GET' && path === '/api/captcha') {
|
|
74
|
+
sendJson(res, 200, await fetchCaptcha(opts.host, url.searchParams.get('key')));
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
if (req.method !== 'POST' || !isSameOrigin(req)) {
|
|
78
|
+
sendJson(res, 403, { error: '请求不合法' });
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
const body = await readJson(req);
|
|
82
|
+
if (path === '/api/login') {
|
|
83
|
+
const t = await passwordLogin(opts, body);
|
|
84
|
+
if (t.error) {
|
|
85
|
+
sendJson(res, 200, { error: t.error });
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
// 在页面上换了账号重新登录:前一个账号的 token 用不上了,撤销掉
|
|
89
|
+
if (pending) {
|
|
90
|
+
revoke(opts.revocationEndpoint, pending.refresh_token);
|
|
91
|
+
}
|
|
92
|
+
pending = t;
|
|
93
|
+
sendJson(res, 200, pickUser(t));
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
if (path === '/api/confirm') {
|
|
97
|
+
const user = pending ? pickUser(pending) : opts.user ? pickUser(opts.user) : null;
|
|
98
|
+
if (!user) {
|
|
99
|
+
sendJson(res, 200, { error: '请先登录' });
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
// 只认列表里的租户,页面传来的值对不上就不设
|
|
103
|
+
const tenant = user.tenants.find((t) => String(t.id) === String(body.tenantId));
|
|
104
|
+
sendJson(res, 200, { ok: true });
|
|
105
|
+
settle.resolve({ token: pending, tenantId: tenant ? tenant.id : null });
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
if (path === '/api/cancel') {
|
|
109
|
+
if (pending) {
|
|
110
|
+
await revoke(opts.revocationEndpoint, pending.refresh_token);
|
|
111
|
+
pending = null;
|
|
112
|
+
}
|
|
113
|
+
sendJson(res, 200, { ok: true });
|
|
114
|
+
settle.reject(new Error('已在浏览器中取消授权'));
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
sendJson(res, 404, { error: '不支持的请求' });
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
await new Promise((resolve, reject) => {
|
|
121
|
+
server.on('error', reject);
|
|
122
|
+
server.listen(0, '127.0.0.1', resolve);
|
|
123
|
+
});
|
|
124
|
+
const pageUrl = `http://127.0.0.1:${server.address().port}/${secret}`;
|
|
125
|
+
console.error(`请在浏览器中登录(${opts.productName}):\n${pageUrl}\n`);
|
|
126
|
+
if (opts.remoteHint) {
|
|
127
|
+
console.error(opts.remoteHint.replace(/\{port\}/g, server.address().port) + '\n');
|
|
128
|
+
}
|
|
129
|
+
if (opts.openBrowser) {
|
|
130
|
+
opts.open(pageUrl);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const timer = setTimeout(() => settle.reject(new Error(`等待登录超时(${Math.round(opts.timeoutMs / 1000)} 秒),请重新执行`)), opts.timeoutMs);
|
|
134
|
+
try {
|
|
135
|
+
return await result;
|
|
136
|
+
} catch (e) {
|
|
137
|
+
// 登录了但没点「授权」就超时:这个 token 不会被保存,撤销掉
|
|
138
|
+
if (pending) {
|
|
139
|
+
await revoke(opts.revocationEndpoint, pending.refresh_token);
|
|
140
|
+
}
|
|
141
|
+
throw e;
|
|
142
|
+
} finally {
|
|
143
|
+
clearTimeout(timer);
|
|
144
|
+
server.close();
|
|
145
|
+
// 浏览器会保持长连接,不断开的话进程要等几秒才退出
|
|
146
|
+
if (server.closeAllConnections) {
|
|
147
|
+
server.closeAllConnections();
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* 账号密码换 token;失败时返回 { error: 给用户看的原因 }
|
|
154
|
+
*/
|
|
155
|
+
async function passwordLogin(opts, body) {
|
|
156
|
+
const username = String(body.username || '').trim();
|
|
157
|
+
const password = String(body.password || '');
|
|
158
|
+
if (!username || !password) {
|
|
159
|
+
return { error: '请输入账号和密码' };
|
|
160
|
+
}
|
|
161
|
+
const form = { grant_type: 'password', username, password, client_id: CLIENT_ID };
|
|
162
|
+
if (opts.captcha) {
|
|
163
|
+
form.captcha = String(body.captcha || '').trim();
|
|
164
|
+
form.check_key = String(body.checkKey || '');
|
|
165
|
+
}
|
|
166
|
+
let res;
|
|
167
|
+
try {
|
|
168
|
+
res = await fetch(opts.tokenEndpoint, {
|
|
169
|
+
method: 'POST',
|
|
170
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
171
|
+
body: new URLSearchParams(form),
|
|
172
|
+
});
|
|
173
|
+
} catch (e) {
|
|
174
|
+
return { error: `连不上 ${opts.host}:${(e.cause && e.cause.code) || e.message}` };
|
|
175
|
+
}
|
|
176
|
+
const t = await res.json().catch(() => ({}));
|
|
177
|
+
if (!res.ok || !t.access_token) {
|
|
178
|
+
return { error: t.error_description || t.message || `登录失败(HTTP ${res.status})` };
|
|
179
|
+
}
|
|
180
|
+
return t;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* 取验证码图片:原来的 /sys/randomImage/{key},返回 data:image/...;base64 开头的图片
|
|
185
|
+
*/
|
|
186
|
+
async function fetchCaptcha(host, key) {
|
|
187
|
+
if (!/^[A-Za-z0-9]{1,64}$/.test(key || '')) {
|
|
188
|
+
return { error: '验证码参数不对' };
|
|
189
|
+
}
|
|
190
|
+
try {
|
|
191
|
+
const res = await fetch(`${host}/sys/randomImage/${key}`);
|
|
192
|
+
const body = await res.json().catch(() => ({}));
|
|
193
|
+
const image = body.result;
|
|
194
|
+
return typeof image === 'string' && image.startsWith('data:image/') ? { image } : { error: body.message || '获取验证码失败' };
|
|
195
|
+
} catch (e) {
|
|
196
|
+
return { error: `连不上 ${host}` };
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* 撤销 refresh_token(对应的 access_token 一起作废),失败不影响主流程
|
|
202
|
+
*/
|
|
203
|
+
async function revoke(endpoint, refreshToken) {
|
|
204
|
+
try {
|
|
205
|
+
await fetch(endpoint, {
|
|
206
|
+
method: 'POST',
|
|
207
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
208
|
+
body: new URLSearchParams({ token: refreshToken, client_id: CLIENT_ID }),
|
|
209
|
+
});
|
|
210
|
+
} catch (e) {
|
|
211
|
+
// 撤销不了就让它自然过期
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** 页面只需要账号和租户,不把 token 交给页面 */
|
|
216
|
+
function pickUser(u) {
|
|
217
|
+
return { username: u.username, realname: u.realname || u.username, tenantId: u.tenant_id == null ? null : u.tenant_id, tenants: Array.isArray(u.tenants) ? u.tenants : [] };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* 只接受页面自己发的请求:别的网站跨域提交时 Origin 是它自己的域名。
|
|
222
|
+
* 不限定 Host:VS Code 远程等端口转发后,浏览器里的地址可能换了端口或域名
|
|
223
|
+
*/
|
|
224
|
+
function isSameOrigin(req) {
|
|
225
|
+
if (!String(req.headers['content-type'] || '').startsWith('application/json')) {
|
|
226
|
+
return false;
|
|
227
|
+
}
|
|
228
|
+
const origin = req.headers.origin;
|
|
229
|
+
return !origin || origin === `http://${req.headers.host}` || origin === `https://${req.headers.host}`;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function readJson(req) {
|
|
233
|
+
return new Promise((resolve, reject) => {
|
|
234
|
+
let size = 0;
|
|
235
|
+
const chunks = [];
|
|
236
|
+
req.on('data', (c) => {
|
|
237
|
+
size += c.length;
|
|
238
|
+
if (size > MAX_BODY) {
|
|
239
|
+
reject(new Error('请求太大'));
|
|
240
|
+
req.destroy();
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
chunks.push(c);
|
|
244
|
+
});
|
|
245
|
+
req.on('end', () => {
|
|
246
|
+
try {
|
|
247
|
+
resolve(JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}'));
|
|
248
|
+
} catch (e) {
|
|
249
|
+
resolve({});
|
|
250
|
+
}
|
|
251
|
+
});
|
|
252
|
+
req.on('error', reject);
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function sendJson(res, status, data) {
|
|
257
|
+
res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store', Connection: 'close' });
|
|
258
|
+
res.end(JSON.stringify(data));
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function sendHtml(res, html, nonce) {
|
|
262
|
+
res.writeHead(200, {
|
|
263
|
+
'Content-Type': 'text/html; charset=utf-8',
|
|
264
|
+
'Cache-Control': 'no-store',
|
|
265
|
+
'X-Frame-Options': 'DENY',
|
|
266
|
+
'Referrer-Policy': 'no-referrer',
|
|
267
|
+
// 页面脚本只允许带 nonce 的那一段;不能被嵌进别的网页(防止诱导点「授权」)
|
|
268
|
+
'Content-Security-Policy': `default-src 'none'; script-src 'nonce-${nonce}'; style-src 'unsafe-inline'; img-src data:; connect-src 'self'; form-action 'none'; frame-ancestors 'none'; base-uri 'none'`,
|
|
269
|
+
Connection: 'close',
|
|
270
|
+
});
|
|
271
|
+
res.end(html);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* 登录页:登录 → 确认租户 → 结果,三步在同一个页面里切换
|
|
276
|
+
*/
|
|
277
|
+
function renderPage(nonce) {
|
|
278
|
+
return `<!DOCTYPE html>
|
|
279
|
+
<html lang="zh-CN">
|
|
280
|
+
<head>
|
|
281
|
+
<meta charset="UTF-8">
|
|
282
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
283
|
+
<title>登录 - jeecg CLI</title>
|
|
284
|
+
<style>
|
|
285
|
+
* { box-sizing: border-box; }
|
|
286
|
+
body { margin: 0; min-height: 100vh; display: flex; align-items: center; justify-content: center; background: #f2f4f7;
|
|
287
|
+
font: 14px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif; color: #1f2329; }
|
|
288
|
+
.card { width: 420px; max-width: calc(100vw - 32px); background: #fff; border-radius: 12px; box-shadow: 0 8px 32px rgba(31, 35, 41, .08); padding: 32px 36px 28px; }
|
|
289
|
+
.brand { display: flex; align-items: center; gap: 12px; padding-bottom: 20px; margin-bottom: 24px; border-bottom: 1px solid #eef0f3; }
|
|
290
|
+
.logo { width: 40px; height: 40px; border-radius: 10px; background: linear-gradient(135deg, #3b82f6, #1d4ed8); color: #fff;
|
|
291
|
+
display: flex; align-items: center; justify-content: center; font-size: 18px; font-weight: 700; flex-shrink: 0; }
|
|
292
|
+
.brand-name { font-size: 16px; font-weight: 600; }
|
|
293
|
+
.muted { font-size: 12px; color: #8f959e; word-break: break-all; }
|
|
294
|
+
h1 { font-size: 20px; margin: 0 0 6px; }
|
|
295
|
+
.sub { color: #646a73; margin: 0 0 22px; }
|
|
296
|
+
.field { display: block; margin-bottom: 16px; }
|
|
297
|
+
.field > span { display: block; margin-bottom: 6px; color: #646a73; font-size: 13px; }
|
|
298
|
+
input, select { width: 100%; height: 40px; padding: 0 12px; border: 1px solid #d5d8dc; border-radius: 8px; font-size: 14px; outline: none; background: #fff; color: inherit; }
|
|
299
|
+
input:focus, select:focus { border-color: #3b82f6; box-shadow: 0 0 0 3px rgba(59, 130, 246, .15); }
|
|
300
|
+
.captcha { display: flex; gap: 10px; }
|
|
301
|
+
.captcha img { height: 40px; width: 120px; border: 1px solid #d5d8dc; border-radius: 8px; cursor: pointer; flex-shrink: 0; }
|
|
302
|
+
.btn { width: 100%; height: 42px; border: 0; border-radius: 8px; background: #2563eb; color: #fff; font-size: 15px; cursor: pointer; }
|
|
303
|
+
.btn:hover { background: #1d4ed8; }
|
|
304
|
+
.btn:disabled { opacity: .6; cursor: default; }
|
|
305
|
+
.btn.ghost { background: #fff; color: #1f2329; border: 1px solid #d5d8dc; }
|
|
306
|
+
.btn.ghost:hover { background: #f5f6f7; }
|
|
307
|
+
.actions { display: flex; gap: 12px; margin-top: 24px; }
|
|
308
|
+
.error { color: #e5484d; min-height: 20px; margin: -4px 0 12px; font-size: 13px; }
|
|
309
|
+
.account { display: flex; align-items: center; gap: 12px; padding: 12px 14px; background: #f7f8fa; border-radius: 10px; margin-bottom: 18px; }
|
|
310
|
+
.avatar { width: 40px; height: 40px; border-radius: 50%; background: #dbeafe; color: #1d4ed8; display: flex; align-items: center; justify-content: center; font-weight: 600; flex-shrink: 0; }
|
|
311
|
+
.account-name { font-weight: 600; }
|
|
312
|
+
.switch { margin-left: auto; color: #2563eb; cursor: pointer; font-size: 13px; background: none; border: 0; padding: 0; }
|
|
313
|
+
.perm { margin: 0; padding: 0; list-style: none; color: #646a73; }
|
|
314
|
+
.perm li { position: relative; padding-left: 20px; margin-bottom: 6px; }
|
|
315
|
+
.perm li::before { content: ""; position: absolute; left: 4px; top: 8px; width: 6px; height: 6px; border-radius: 50%; background: #3b82f6; }
|
|
316
|
+
.result { text-align: center; padding: 8px 0 4px; }
|
|
317
|
+
.result-icon { width: 56px; height: 56px; margin: 0 auto 14px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 28px; color: #fff; background: #22c55e; }
|
|
318
|
+
.result-icon.cancel { background: #9ca3af; }
|
|
319
|
+
.hidden { display: none !important; }
|
|
320
|
+
</style>
|
|
321
|
+
</head>
|
|
322
|
+
<body>
|
|
323
|
+
<div class="card">
|
|
324
|
+
<div class="brand">
|
|
325
|
+
<div class="logo">J</div>
|
|
326
|
+
<div><div class="brand-name" id="product"></div><div class="muted" id="host"></div></div>
|
|
327
|
+
</div>
|
|
328
|
+
|
|
329
|
+
<form id="login" class="hidden" autocomplete="on">
|
|
330
|
+
<h1>登录</h1>
|
|
331
|
+
<p class="sub">登录后授权 jeecg CLI 以你的身份调用接口</p>
|
|
332
|
+
<label class="field"><span>账号</span><input name="username" autocomplete="username" required></label>
|
|
333
|
+
<label class="field"><span>密码</span><input name="password" type="password" autocomplete="current-password" required></label>
|
|
334
|
+
<div class="field hidden" id="captchaRow">
|
|
335
|
+
<span>验证码</span>
|
|
336
|
+
<div class="captcha"><input name="captcha" autocomplete="off" maxlength="8"><img id="captchaImg" alt="验证码" title="看不清?点击换一张"></div>
|
|
337
|
+
</div>
|
|
338
|
+
<div class="error" id="loginError"></div>
|
|
339
|
+
<button class="btn" type="submit">登录</button>
|
|
340
|
+
</form>
|
|
341
|
+
|
|
342
|
+
<div id="confirm" class="hidden">
|
|
343
|
+
<h1>授权 jeecg CLI</h1>
|
|
344
|
+
<p class="sub">jeecg CLI 申请以你的身份访问 <span id="product2"></span></p>
|
|
345
|
+
<div class="account">
|
|
346
|
+
<div class="avatar" id="avatar"></div>
|
|
347
|
+
<div><div class="account-name" id="realname"></div><div class="muted" id="username"></div></div>
|
|
348
|
+
<button class="switch" id="switch" type="button">切换账号</button>
|
|
349
|
+
</div>
|
|
350
|
+
<label class="field" id="tenantRow"><span>租户</span><select id="tenant"></select></label>
|
|
351
|
+
<ul class="perm">
|
|
352
|
+
<li>以你的身份调用接口,供 AI Skills 使用</li>
|
|
353
|
+
<li>实际权限不会超过你在系统中的权限</li>
|
|
354
|
+
</ul>
|
|
355
|
+
<div class="error" id="confirmError"></div>
|
|
356
|
+
<div class="actions">
|
|
357
|
+
<button class="btn ghost" id="cancel" type="button">取消</button>
|
|
358
|
+
<button class="btn" id="approve" type="button">授权</button>
|
|
359
|
+
</div>
|
|
360
|
+
</div>
|
|
361
|
+
|
|
362
|
+
<div id="done" class="hidden result">
|
|
363
|
+
<div class="result-icon" id="doneIcon">✓</div>
|
|
364
|
+
<h1 id="doneTitle"></h1>
|
|
365
|
+
<p class="sub" id="doneText"></p>
|
|
366
|
+
</div>
|
|
367
|
+
</div>
|
|
368
|
+
<script nonce="${nonce}">
|
|
369
|
+
var base = location.pathname.replace(/\\/$/, '');
|
|
370
|
+
var state = null;
|
|
371
|
+
var checkKey = '';
|
|
372
|
+
function $(id) { return document.getElementById(id); }
|
|
373
|
+
function show(id) { ['login', 'confirm', 'done'].forEach(function (v) { $(v).classList.toggle('hidden', v !== id); }); }
|
|
374
|
+
function api(path, body) {
|
|
375
|
+
var init = body === undefined ? {} : { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) };
|
|
376
|
+
return fetch(base + '/api/' + path, init).then(function (r) { return r.json(); });
|
|
377
|
+
}
|
|
378
|
+
function lost() { showDone(false, '连接已断开', 'jeecg CLI 已经退出,请回到终端重新执行登录命令。'); }
|
|
379
|
+
function showDone(ok, title, text) {
|
|
380
|
+
$('doneIcon').textContent = ok ? '✓' : '×';
|
|
381
|
+
$('doneIcon').classList.toggle('cancel', !ok);
|
|
382
|
+
$('doneTitle').textContent = title;
|
|
383
|
+
$('doneText').textContent = text;
|
|
384
|
+
show('done');
|
|
385
|
+
}
|
|
386
|
+
function refreshCaptcha() {
|
|
387
|
+
if (!state.captcha) { return; }
|
|
388
|
+
checkKey = Date.now() + '' + Math.floor(Math.random() * 1000);
|
|
389
|
+
api('captcha?key=' + checkKey).then(function (r) {
|
|
390
|
+
if (r.image) { $('captchaImg').src = r.image; } else { $('loginError').textContent = r.error || '获取验证码失败'; }
|
|
391
|
+
}).catch(lost);
|
|
392
|
+
}
|
|
393
|
+
function showLogin() {
|
|
394
|
+
show('login');
|
|
395
|
+
$('loginError').textContent = '';
|
|
396
|
+
refreshCaptcha();
|
|
397
|
+
document.forms.login.username.focus();
|
|
398
|
+
}
|
|
399
|
+
function showConfirm(user) {
|
|
400
|
+
$('realname').textContent = user.realname;
|
|
401
|
+
$('username').textContent = user.username;
|
|
402
|
+
$('avatar').textContent = (user.realname || '?').slice(0, 1);
|
|
403
|
+
var select = $('tenant');
|
|
404
|
+
select.innerHTML = '';
|
|
405
|
+
user.tenants.forEach(function (t) {
|
|
406
|
+
var option = document.createElement('option');
|
|
407
|
+
option.value = t.id;
|
|
408
|
+
option.textContent = t.name;
|
|
409
|
+
option.selected = String(t.id) === String(user.tenantId);
|
|
410
|
+
select.appendChild(option);
|
|
411
|
+
});
|
|
412
|
+
$('tenantRow').classList.toggle('hidden', user.tenants.length === 0);
|
|
413
|
+
$('confirmError').textContent = '';
|
|
414
|
+
show('confirm');
|
|
415
|
+
}
|
|
416
|
+
document.forms.login.addEventListener('submit', function (e) {
|
|
417
|
+
e.preventDefault();
|
|
418
|
+
var form = e.target;
|
|
419
|
+
var button = form.querySelector('button');
|
|
420
|
+
button.disabled = true;
|
|
421
|
+
$('loginError').textContent = '';
|
|
422
|
+
api('login', { username: form.username.value, password: form.password.value, captcha: form.captcha.value, checkKey: checkKey }).then(function (r) {
|
|
423
|
+
if (r.error) {
|
|
424
|
+
$('loginError').textContent = r.error;
|
|
425
|
+
form.captcha.value = '';
|
|
426
|
+
refreshCaptcha();
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
form.password.value = '';
|
|
430
|
+
form.captcha.value = '';
|
|
431
|
+
showConfirm(r);
|
|
432
|
+
}).catch(lost).then(function () { button.disabled = false; });
|
|
433
|
+
});
|
|
434
|
+
$('captchaImg').addEventListener('click', refreshCaptcha);
|
|
435
|
+
$('switch').addEventListener('click', showLogin);
|
|
436
|
+
$('approve').addEventListener('click', function () {
|
|
437
|
+
$('approve').disabled = true;
|
|
438
|
+
api('confirm', { tenantId: $('tenant').value }).then(function (r) {
|
|
439
|
+
if (r.error) { $('confirmError').textContent = r.error; return; }
|
|
440
|
+
showDone(true, '授权成功', '可以关闭此页面,回到终端继续使用。');
|
|
441
|
+
}).catch(lost).then(function () { $('approve').disabled = false; });
|
|
442
|
+
});
|
|
443
|
+
$('cancel').addEventListener('click', function () {
|
|
444
|
+
api('cancel', {}).then(function () { showDone(false, '已取消授权', '可以关闭此页面。'); }).catch(lost);
|
|
445
|
+
});
|
|
446
|
+
api('init').then(function (r) {
|
|
447
|
+
state = r;
|
|
448
|
+
$('product').textContent = r.product;
|
|
449
|
+
$('product2').textContent = r.product;
|
|
450
|
+
$('host').textContent = r.host;
|
|
451
|
+
$('captchaRow').classList.toggle('hidden', !r.captcha);
|
|
452
|
+
if (r.user) { showConfirm(r.user); } else { showLogin(); }
|
|
453
|
+
}).catch(lost);
|
|
454
|
+
</script>
|
|
455
|
+
</body>
|
|
456
|
+
</html>`;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
module.exports = { pageLogin };
|
package/lib/secret.js
CHANGED
|
@@ -4,14 +4,17 @@
|
|
|
4
4
|
* access_token 有效期短,而且每次 jeecg auth token 都要读,保持原样,取 token 不变慢。
|
|
5
5
|
*
|
|
6
6
|
* - Windows:DPAPI,系统自带,密文和当前 Windows 账号绑定(拷到别的电脑或账号解不开);通过 PowerShell 调用,一次约 0.35 秒
|
|
7
|
-
* - macOS:系统钥匙串(security
|
|
7
|
+
* - macOS:系统钥匙串(security 命令);钥匙串锁着时(例如 SSH 登录)退回明文
|
|
8
8
|
* - Linux:有 secret-tool 就存进系统密钥环;没有(服务器上常见)或调用失败,就退回明文(文件权限 600)并提示
|
|
9
|
+
* - WSL、Docker 容器等其它 Linux 环境同上,一般没有密钥环,走明文
|
|
9
10
|
*
|
|
10
11
|
* credentials.json 里存的值带前缀说明存在哪:dpapi:<密文> / keychain: / secret-tool: ;没有前缀的是明文(以前存的或退回明文的)
|
|
11
12
|
*/
|
|
12
13
|
const { execFileSync } = require('child_process');
|
|
13
14
|
|
|
14
15
|
const SERVICE = 'jeecg-cli';
|
|
16
|
+
// 调系统命令最多等 30 秒:Linux 密钥环被锁时会弹窗等用户输密码,等不到就退回明文,不让命令一直卡着
|
|
17
|
+
const TIMEOUT_MS = 30 * 1000;
|
|
15
18
|
// 明文和密文都从环境变量传给 PowerShell,不出现在进程命令行里
|
|
16
19
|
const PROTECT_PS =
|
|
17
20
|
"Add-Type -AssemblyName System.Security;[Convert]::ToBase64String([Security.Cryptography.ProtectedData]::Protect([Text.Encoding]::UTF8.GetBytes($env:JEECG_SECRET),$null,'CurrentUser'))";
|
|
@@ -26,13 +29,19 @@ function protect(host, secret) {
|
|
|
26
29
|
if (process.platform === 'win32') {
|
|
27
30
|
return 'dpapi:' + powershell(PROTECT_PS, secret);
|
|
28
31
|
}
|
|
32
|
+
// 存完读回来比对一次:security -i 里的命令失败时退出码也可能是 0(例如 SSH 登录的 Mac 钥匙串是锁着的),
|
|
33
|
+
// 不比对的话会以为存进去了,之后读不出来,每次 token 过期都要重新授权
|
|
34
|
+
const stored = process.platform === 'darwin' ? 'keychain:' : 'secret-tool:';
|
|
29
35
|
if (process.platform === 'darwin') {
|
|
30
36
|
// security -i 从标准输入读命令,密钥不出现在进程命令行里
|
|
31
|
-
execFileSync('security', ['-i'], { input: `add-generic-password -U -a "${host}" -s "${SERVICE}" -w "${secret}"\n`, stdio: ['pipe', 'ignore', 'pipe'] });
|
|
32
|
-
|
|
37
|
+
execFileSync('security', ['-i'], { input: `add-generic-password -U -a "${host}" -s "${SERVICE}" -w "${secret}"\n`, stdio: ['pipe', 'ignore', 'pipe'], timeout: TIMEOUT_MS });
|
|
38
|
+
} else {
|
|
39
|
+
execFileSync('secret-tool', ['store', `--label=jeecg CLI ${host}`, 'service', SERVICE, 'account', host], { input: secret, stdio: ['pipe', 'ignore', 'pipe'], timeout: TIMEOUT_MS });
|
|
33
40
|
}
|
|
34
|
-
|
|
35
|
-
|
|
41
|
+
if (unprotect(host, stored) !== secret) {
|
|
42
|
+
throw new Error('写入后读不出来');
|
|
43
|
+
}
|
|
44
|
+
return stored;
|
|
36
45
|
} catch (e) {
|
|
37
46
|
console.error(`提示:本机没有可用的系统密钥存储(${e.code || e.message}),refresh_token 以明文保存在 ~/.jeecg/credentials.json(文件权限 600)`);
|
|
38
47
|
return secret;
|
|
@@ -51,10 +60,10 @@ function unprotect(host, stored) {
|
|
|
51
60
|
return powershell(UNPROTECT_PS, stored.slice('dpapi:'.length));
|
|
52
61
|
}
|
|
53
62
|
if (stored === 'keychain:') {
|
|
54
|
-
return execFileSync('security', ['find-generic-password', '-a', host, '-s', SERVICE, '-w'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
|
|
63
|
+
return execFileSync('security', ['find-generic-password', '-a', host, '-s', SERVICE, '-w'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: TIMEOUT_MS }).trim();
|
|
55
64
|
}
|
|
56
65
|
if (stored === 'secret-tool:') {
|
|
57
|
-
return execFileSync('secret-tool', ['lookup', 'service', SERVICE, 'account', host], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
|
|
66
|
+
return execFileSync('secret-tool', ['lookup', 'service', SERVICE, 'account', host], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: TIMEOUT_MS }).trim();
|
|
58
67
|
}
|
|
59
68
|
} catch (e) {
|
|
60
69
|
return null;
|
|
@@ -68,9 +77,9 @@ function unprotect(host, stored) {
|
|
|
68
77
|
function remove(host, stored) {
|
|
69
78
|
try {
|
|
70
79
|
if (stored === 'keychain:') {
|
|
71
|
-
execFileSync('security', ['delete-generic-password', '-a', host, '-s', SERVICE], { stdio: 'ignore' });
|
|
80
|
+
execFileSync('security', ['delete-generic-password', '-a', host, '-s', SERVICE], { stdio: 'ignore', timeout: TIMEOUT_MS });
|
|
72
81
|
} else if (stored === 'secret-tool:') {
|
|
73
|
-
execFileSync('secret-tool', ['clear', 'service', SERVICE, 'account', host], { stdio: 'ignore' });
|
|
82
|
+
execFileSync('secret-tool', ['clear', 'service', SERVICE, 'account', host], { stdio: 'ignore', timeout: TIMEOUT_MS });
|
|
74
83
|
}
|
|
75
84
|
} catch (e) {
|
|
76
85
|
// 本来就没有,不用处理
|
|
@@ -83,6 +92,7 @@ function powershell(script, input) {
|
|
|
83
92
|
encoding: 'utf8',
|
|
84
93
|
windowsHide: true,
|
|
85
94
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
95
|
+
timeout: TIMEOUT_MS,
|
|
86
96
|
}).trim();
|
|
87
97
|
}
|
|
88
98
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ws1298191366/cli",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "JEECG 命令行工具:在浏览器里授权登录 JeecgBoot /
|
|
3
|
+
"version": "0.1.3",
|
|
4
|
+
"description": "JEECG 命令行工具:在浏览器里授权登录 JeecgBoot / 敲敲云 / 积木报表,给 AI skills 提供 token",
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"registry": "https://registry.npmjs.org",
|
|
7
7
|
"access": "public"
|