jpsclient 2.4.1 → 2.7.0
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.
- checksums.yaml +4 -4
- data/lib/jpsclient/api/bug.rb +26 -0
- data/lib/jpsclient/api/category.rb +16 -206
- data/lib/jpsclient/api/lark_task.rb +71 -0
- data/lib/jpsclient/api/material.rb +353 -0
- data/lib/jpsclient/api/project_package.rb +2 -2
- data/lib/jpsclient/auth/auth.rb +194 -579
- data/lib/jpsclient/auth/endpoints.rb +46 -0
- data/lib/jpsclient/auth/token.rb +157 -114
- data/lib/jpsclient/base/client.rb +12 -6
- data/lib/jpsclient/http/http_client.rb +25 -11
- data/lib/jpsclient/upload/upload_client.rb +5 -5
- data/lib/jpsclient/utils/debug.rb +20 -0
- data/lib/jpsclient/utils/logger.rb +1 -1
- data/lib/jpsclient/version.rb +1 -1
- data/lib/jpsclient.rb +3 -0
- metadata +11 -10
data/lib/jpsclient/auth/auth.rb
CHANGED
|
@@ -1,37 +1,41 @@
|
|
|
1
1
|
#!/usr/bin/env ruby
|
|
2
2
|
|
|
3
|
-
require 'webrick'
|
|
4
3
|
require 'json'
|
|
5
4
|
require 'net/http'
|
|
6
5
|
require 'net/https'
|
|
7
6
|
require 'uri'
|
|
7
|
+
require 'cgi'
|
|
8
8
|
require 'securerandom'
|
|
9
|
-
require 'digest'
|
|
10
|
-
require 'base64'
|
|
11
|
-
require 'fileutils'
|
|
12
|
-
require 'jpsclient/utils/aes'
|
|
13
9
|
require 'jpsclient/base/exception'
|
|
10
|
+
require 'jpsclient/utils/debug'
|
|
14
11
|
require 'jpsclient/utils/logger'
|
|
12
|
+
require 'jpsclient/auth/endpoints'
|
|
13
|
+
require 'jpsclient/version'
|
|
15
14
|
|
|
16
15
|
module JPSClient
|
|
17
16
|
|
|
18
17
|
# JPS 登录配置类
|
|
18
|
+
#
|
|
19
|
+
# 迁移到 BFF 中心化 OIDC 后,多数字段(client_id / feishu_auth_url / server_port / scope)已不
|
|
20
|
+
# 再使用,仅保留以兼容旧配置文件。当前实际使用:
|
|
21
|
+
# - api_endpoint → 推导 API 域(authorize / oidc token / logout)
|
|
22
|
+
# - redirect_uri → 推导 WEB 域(Origin / Referer / redirectUri)
|
|
23
|
+
# - aes_key → 凭证加解密
|
|
19
24
|
class LoginConfig
|
|
20
25
|
attr_accessor :client_id, :feishu_auth_url, :redirect_uri, :api_endpoint, :state, :server_port
|
|
21
26
|
attr_accessor :aes_key
|
|
22
27
|
attr_accessor :token_dir, :token_file_name
|
|
23
28
|
|
|
24
29
|
def initialize
|
|
25
|
-
# 所有配置必须从外部配置文件加载,不提供硬编码的默认值
|
|
26
30
|
@client_id = nil
|
|
27
31
|
@feishu_auth_url = nil
|
|
28
32
|
@redirect_uri = nil
|
|
29
33
|
@api_endpoint = nil
|
|
30
|
-
@state = '
|
|
31
|
-
@server_port =
|
|
32
|
-
@aes_key = nil
|
|
33
|
-
@token_dir = nil
|
|
34
|
-
@token_file_name = nil
|
|
34
|
+
@state = 'oidc_login'
|
|
35
|
+
@server_port = nil
|
|
36
|
+
@aes_key = nil
|
|
37
|
+
@token_dir = nil
|
|
38
|
+
@token_file_name = nil
|
|
35
39
|
end
|
|
36
40
|
|
|
37
41
|
# 从 JSON 加载配置
|
|
@@ -41,7 +45,6 @@ module JPSClient
|
|
|
41
45
|
|
|
42
46
|
data = json_data.is_a?(String) ? JSON.parse(json_data) : json_data
|
|
43
47
|
|
|
44
|
-
# 加载所有配置字段
|
|
45
48
|
config.client_id = data['client_id'] if data['client_id']
|
|
46
49
|
config.feishu_auth_url = data['feishu_auth_url'] if data['feishu_auth_url']
|
|
47
50
|
config.redirect_uri = data['redirect_uri'] if data['redirect_uri']
|
|
@@ -49,8 +52,8 @@ module JPSClient
|
|
|
49
52
|
config.state = data['state'] if data['state']
|
|
50
53
|
config.server_port = data['server_port'] if data['server_port']
|
|
51
54
|
config.aes_key = data['aes_key'] if data['aes_key']
|
|
52
|
-
config.token_dir = data['token_dir'] if data['token_dir']
|
|
53
|
-
config.token_file_name = data['token_file_name'] if data['token_file_name']
|
|
55
|
+
config.token_dir = data['token_dir'] if data['token_dir']
|
|
56
|
+
config.token_file_name = data['token_file_name'] if data['token_file_name']
|
|
54
57
|
|
|
55
58
|
config
|
|
56
59
|
end
|
|
@@ -62,43 +65,30 @@ module JPSClient
|
|
|
62
65
|
end
|
|
63
66
|
end
|
|
64
67
|
|
|
68
|
+
# JPS 中心化登录(BFF OIDC)。
|
|
69
|
+
#
|
|
70
|
+
# 流程(见 OIDC 接入文档 §5.2/§5.3):自生成一次性 code → 拉起系统浏览器打开 authorize →
|
|
71
|
+
# 浏览器侧完成飞书 SSO 并按 code 绑定登录态 → 客户端凭同一 code 轮询 /api/oidc/token 取回登录态。
|
|
72
|
+
# 拿到的 token 即会话凭证(业务请求作 SESSION Cookie 携带)。
|
|
65
73
|
class Auth
|
|
66
74
|
attr_reader :access_token, :username
|
|
67
75
|
attr_reader :user_id, :permissions, :lark_user_id, :tenant_manager
|
|
76
|
+
attr_reader :avatar, :token_expire_timestamp
|
|
77
|
+
|
|
78
|
+
LOGIN_ENTRY_PATH = '/auth/jwt/login'.freeze
|
|
79
|
+
OIDC_TOKEN_PATH = '/api/oidc/token'.freeze
|
|
80
|
+
OIDC_STATE = 'oidc_login'.freeze
|
|
81
|
+
|
|
82
|
+
# 轮询:覆盖用户在浏览器里扫码 + 飞书 SSO 的耗时,取宽松值
|
|
83
|
+
POLL_INTERVAL = 2.0
|
|
84
|
+
POLL_TIMEOUT = 300.0
|
|
68
85
|
|
|
69
86
|
def initialize(config = nil)
|
|
70
|
-
# 只接受 LoginConfig 对象或默认配置
|
|
71
87
|
@config = config || LoginConfig.new
|
|
72
88
|
|
|
73
|
-
#
|
|
74
|
-
@
|
|
75
|
-
@
|
|
76
|
-
@redirect_uri = @config.redirect_uri
|
|
77
|
-
@api_endpoint = @config.api_endpoint
|
|
78
|
-
@state = @config.state
|
|
79
|
-
@server_port = @config.server_port
|
|
80
|
-
|
|
81
|
-
@scope_list = [
|
|
82
|
-
'offline_access',
|
|
83
|
-
'task:task:write',
|
|
84
|
-
'task:section:write',
|
|
85
|
-
'task:custom_field:write',
|
|
86
|
-
'task:tasklist:write',
|
|
87
|
-
'drive:drive',
|
|
88
|
-
'wiki:wiki',
|
|
89
|
-
'docx:document',
|
|
90
|
-
'bitable:app',
|
|
91
|
-
'contact:user.employee_id:readonly',
|
|
92
|
-
'docs:document.content:read',
|
|
93
|
-
'im:chat',
|
|
94
|
-
'base:app:copy',
|
|
95
|
-
'base:record:update',
|
|
96
|
-
'task:comment:write',
|
|
97
|
-
'task:comment',
|
|
98
|
-
'task:attachment:write',
|
|
99
|
-
'vc:room:readonly',
|
|
100
|
-
'vc:meeting:readonly'
|
|
101
|
-
]
|
|
89
|
+
# 从 config 推导 API / WEB 域,缺失时回退固定正式服地址
|
|
90
|
+
@api_base = Endpoints.origin_of(@config.api_endpoint, Endpoints::DEFAULT_API_BASE)
|
|
91
|
+
@web_origin = Endpoints.origin_of(@config.redirect_uri, Endpoints::DEFAULT_WEB_ORIGIN)
|
|
102
92
|
|
|
103
93
|
@access_token = nil
|
|
104
94
|
@username = nil
|
|
@@ -106,595 +96,220 @@ module JPSClient
|
|
|
106
96
|
@permissions = nil
|
|
107
97
|
@lark_user_id = nil
|
|
108
98
|
@tenant_manager = false
|
|
99
|
+
@avatar = nil
|
|
100
|
+
@token_expire_timestamp = 0
|
|
109
101
|
|
|
110
|
-
|
|
111
|
-
@verbose = ENV['PINDO_DEBUG'] == 'true'
|
|
102
|
+
@verbose = JPSClient.debug?
|
|
112
103
|
end
|
|
113
104
|
|
|
114
|
-
#
|
|
105
|
+
# 主登录入口。成功返回 true,用户取消返回 :user_cancelled,失败返回 false
|
|
115
106
|
def login
|
|
116
107
|
puts "🔐 需要登录 JPS..."
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
return result
|
|
108
|
+
oidc_login
|
|
109
|
+
rescue Interrupt
|
|
110
|
+
puts "\n用户中断登录操作"
|
|
111
|
+
:user_cancelled
|
|
123
112
|
end
|
|
124
113
|
|
|
125
|
-
#
|
|
114
|
+
# 兼容旧接口
|
|
126
115
|
def authorize
|
|
127
116
|
login
|
|
128
117
|
end
|
|
129
118
|
|
|
130
|
-
# 获取用户名(登录后可用)
|
|
131
119
|
def get_username
|
|
132
120
|
@username
|
|
133
121
|
end
|
|
134
122
|
|
|
135
|
-
# 获取 token 数据(供
|
|
123
|
+
# 获取 token 数据(供 Token#save 使用,内部 snake 键)
|
|
136
124
|
def get_token_data
|
|
137
125
|
return nil unless @access_token
|
|
138
126
|
|
|
139
127
|
{
|
|
140
|
-
'token'
|
|
141
|
-
'username'
|
|
142
|
-
'user_id'
|
|
143
|
-
'permissions'
|
|
144
|
-
'lark_user_id'
|
|
145
|
-
'tenant_manager'
|
|
128
|
+
'token' => @access_token,
|
|
129
|
+
'username' => @username,
|
|
130
|
+
'user_id' => @user_id,
|
|
131
|
+
'permissions' => @permissions,
|
|
132
|
+
'lark_user_id' => @lark_user_id,
|
|
133
|
+
'tenant_manager' => @tenant_manager,
|
|
134
|
+
'avatar' => @avatar,
|
|
135
|
+
'token_expire_timestamp' => @token_expire_timestamp
|
|
146
136
|
}
|
|
147
137
|
end
|
|
148
138
|
|
|
149
|
-
|
|
150
|
-
def authorize_and_login
|
|
151
|
-
# 构建授权 URL
|
|
152
|
-
authorization_uri = build_authorization_uri
|
|
153
|
-
puts "正在打开浏览器进行飞书 OAuth 授权..."
|
|
154
|
-
puts "\n授权 URL(如自动打开失败,请手动复制下面的链接到浏览器):"
|
|
155
|
-
puts "=" * 80
|
|
156
|
-
puts authorization_uri
|
|
157
|
-
puts "=" * 80
|
|
158
|
-
puts ""
|
|
159
|
-
|
|
160
|
-
# 在浏览器中打开授权 URL
|
|
161
|
-
open_browser(authorization_uri)
|
|
162
|
-
|
|
163
|
-
# 启动本地服务器处理回调
|
|
164
|
-
code = start_callback_server
|
|
165
|
-
|
|
166
|
-
# 如果自动获取失败,提示用户手动输入
|
|
167
|
-
if code.nil?
|
|
168
|
-
loop do
|
|
169
|
-
puts "\n自动获取授权码失败,请选择:"
|
|
170
|
-
puts "1. 输入授权码 (直接复制 'code=' 后面的内容)"
|
|
171
|
-
puts "2. 输入完整回调 URL"
|
|
172
|
-
puts "3. 重新打开授权网页"
|
|
173
|
-
puts "4. 退出"
|
|
174
|
-
print "> "
|
|
175
|
-
|
|
176
|
-
begin
|
|
177
|
-
choice = STDIN.gets&.chomp
|
|
178
|
-
|
|
179
|
-
# 处理 Ctrl+C 中断
|
|
180
|
-
if choice.nil?
|
|
181
|
-
puts "\n用户中断操作"
|
|
182
|
-
return :user_cancelled
|
|
183
|
-
end
|
|
184
|
-
|
|
185
|
-
case choice
|
|
186
|
-
when "1"
|
|
187
|
-
puts "请输入授权码:"
|
|
188
|
-
print "> "
|
|
189
|
-
code_input = STDIN.gets&.chomp
|
|
190
|
-
if code_input.nil?
|
|
191
|
-
puts "用户中断操作"
|
|
192
|
-
return :user_cancelled
|
|
193
|
-
elsif !code_input.empty?
|
|
194
|
-
code = code_input
|
|
195
|
-
break
|
|
196
|
-
else
|
|
197
|
-
puts "授权码不能为空,请重新选择"
|
|
198
|
-
end
|
|
199
|
-
when "2"
|
|
200
|
-
puts "请输入完整回调 URL:"
|
|
201
|
-
print "> "
|
|
202
|
-
url_input = STDIN.gets&.chomp
|
|
203
|
-
if url_input.nil?
|
|
204
|
-
puts "用户中断操作"
|
|
205
|
-
return :user_cancelled
|
|
206
|
-
elsif url_input.start_with?("http")
|
|
207
|
-
# 尝试从 URL 中提取 code
|
|
208
|
-
begin
|
|
209
|
-
uri = URI(url_input)
|
|
210
|
-
query_params = URI.decode_www_form(uri.query || '').to_h
|
|
211
|
-
code = query_params['code']
|
|
212
|
-
if code
|
|
213
|
-
puts "✓ 从 URL 中成功提取授权码"
|
|
214
|
-
break
|
|
215
|
-
else
|
|
216
|
-
puts "✗ URL 中没有找到授权码,请重新选择"
|
|
217
|
-
end
|
|
218
|
-
rescue => e
|
|
219
|
-
puts "✗ 无法从 URL 中提取授权码: #{e.message}"
|
|
220
|
-
end
|
|
221
|
-
else
|
|
222
|
-
puts "✗ 无效的 URL 格式,请重新选择"
|
|
223
|
-
end
|
|
224
|
-
when "3"
|
|
225
|
-
# 重新打开授权网页
|
|
226
|
-
puts "正在重新打开授权网页..."
|
|
227
|
-
open_browser(authorization_uri)
|
|
228
|
-
# 重新启动服务器尝试获取授权码
|
|
229
|
-
code = start_callback_server
|
|
230
|
-
if code
|
|
231
|
-
break
|
|
232
|
-
end
|
|
233
|
-
# 如果还是失败,继续循环让用户选择
|
|
234
|
-
when "4"
|
|
235
|
-
puts "已退出授权流程"
|
|
236
|
-
return :user_cancelled
|
|
237
|
-
else
|
|
238
|
-
puts "无效的选择,请输入 1-4 之间的数字"
|
|
239
|
-
end
|
|
240
|
-
rescue Interrupt
|
|
241
|
-
puts "\n\n用户中断操作"
|
|
242
|
-
return :user_cancelled
|
|
243
|
-
end
|
|
244
|
-
end
|
|
245
|
-
end
|
|
139
|
+
private
|
|
246
140
|
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
else
|
|
254
|
-
puts "✗ 授权失败"
|
|
141
|
+
# OIDC 登录:自生成 code → 拉起浏览器 → 轮询取登录态
|
|
142
|
+
def oidc_login
|
|
143
|
+
# 非交互 / CI 环境无法完成浏览器飞书授权,直接快速失败提示,避免弹浏览器并空转 5 分钟
|
|
144
|
+
unless interactive?
|
|
145
|
+
puts "⚠️ 检测到非交互环境(无 TTY 或 CI),无法弹出浏览器完成飞书授权。"
|
|
146
|
+
puts " 请在交互式终端登录后再运行。"
|
|
255
147
|
return false
|
|
256
148
|
end
|
|
257
|
-
end
|
|
258
|
-
|
|
259
|
-
# 使用授权码换取 token
|
|
260
|
-
def exchange_code_for_token(code)
|
|
261
|
-
begin
|
|
262
|
-
puts "🔄 获取授权码成功: #{code[0..10]}..."
|
|
263
|
-
puts "🔄 正在换取访问令牌..."
|
|
264
|
-
|
|
265
|
-
request_data = {
|
|
266
|
-
'code' => code,
|
|
267
|
-
'redirectUri' => @redirect_uri,
|
|
268
|
-
'scope' => @scope_list.join(' ')
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
uri = URI(@api_endpoint)
|
|
272
|
-
http = Net::HTTP.new(uri.host, uri.port)
|
|
273
|
-
http.use_ssl = (uri.scheme == 'https')
|
|
274
|
-
|
|
275
|
-
# 配置 SSL 以兼容 OpenSSL 3.x(避免 CRL 检查失败)
|
|
276
|
-
if http.use_ssl?
|
|
277
|
-
http.verify_mode = OpenSSL::SSL::VERIFY_PEER
|
|
278
|
-
# 创建 SSL 上下文并禁用 CRL 检查
|
|
279
|
-
http.cert_store = OpenSSL::X509::Store.new
|
|
280
|
-
http.cert_store.set_default_paths
|
|
281
|
-
# verify_flags = 0 表示不检查 CRL
|
|
282
|
-
http.cert_store.flags = 0
|
|
283
|
-
end
|
|
284
|
-
|
|
285
|
-
request = Net::HTTP::Post.new(uri)
|
|
286
|
-
request['Content-Type'] = 'application/json'
|
|
287
|
-
request.body = request_data.to_json
|
|
288
|
-
|
|
289
|
-
puts "正在请求 JPS API: #{@api_endpoint}" if @verbose
|
|
290
|
-
response = http.request(request)
|
|
291
|
-
|
|
292
|
-
puts "API 响应状态码: #{response.code}" if @verbose
|
|
293
|
-
|
|
294
|
-
if response.body
|
|
295
|
-
result = JSON.parse(response.body)
|
|
296
|
-
puts "API 响应: #{result.inspect}" if @verbose
|
|
297
|
-
|
|
298
|
-
if result['meta'] && result['meta']['code'] == 0 && result['data']
|
|
299
|
-
data = result['data']
|
|
300
|
-
# 兼容 snake_case 和 camelCase 两种响应格式
|
|
301
|
-
@access_token = data['token'] if data['token']
|
|
302
|
-
@username = data['username'] if data['username']
|
|
303
|
-
@user_id = data['user_id'] || data['userId']
|
|
304
|
-
@permissions = data['permissions']
|
|
305
|
-
@lark_user_id = data['lark_user_id'] || data['larkUserId']
|
|
306
|
-
@tenant_manager = data['tenant_manager'] || data['tenantManager'] || false
|
|
307
|
-
|
|
308
|
-
return true if @access_token
|
|
309
|
-
else
|
|
310
|
-
error_msg = result['meta'] && result['meta']['message'] ? result['meta']['message'] : '未知错误'
|
|
311
|
-
puts "JPS 登录失败: #{error_msg}"
|
|
312
|
-
end
|
|
313
|
-
else
|
|
314
|
-
puts "API 返回空响应" if @verbose
|
|
315
|
-
end
|
|
316
149
|
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
150
|
+
code = SecureRandom.hex(16)
|
|
151
|
+
authorize_url = build_authorize_url(code)
|
|
152
|
+
|
|
153
|
+
puts "正在打开系统浏览器完成飞书授权..."
|
|
154
|
+
opened = open_browser(authorize_url)
|
|
155
|
+
unless opened
|
|
156
|
+
# 仅在自动打开失败时才输出含一次性 code 的完整链接,避免 code 泄露进日志被竞速利用
|
|
157
|
+
puts "\n自动打开浏览器失败,请手动复制下面的链接到浏览器:"
|
|
158
|
+
puts "=" * 80
|
|
159
|
+
puts authorize_url
|
|
160
|
+
puts "=" * 80
|
|
321
161
|
end
|
|
322
|
-
|
|
162
|
+
puts ""
|
|
323
163
|
|
|
164
|
+
puts "🔄 正在等待浏览器完成授权(请在弹出的页面用飞书扫码/授权)..."
|
|
165
|
+
data = poll_login_vo(code)
|
|
166
|
+
return false unless data
|
|
324
167
|
|
|
325
|
-
|
|
168
|
+
apply_login_vo(data)
|
|
169
|
+
return false if @access_token.to_s.empty?
|
|
326
170
|
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
case RbConfig::CONFIG['host_os']
|
|
330
|
-
when /mswin|mingw|cygwin/
|
|
331
|
-
'NUL'
|
|
332
|
-
else
|
|
333
|
-
'/dev/null'
|
|
334
|
-
end
|
|
171
|
+
puts "✓ JPS 登录成功!用户名: #{@username}"
|
|
172
|
+
true
|
|
335
173
|
end
|
|
336
174
|
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
'client_id' => @client_id,
|
|
344
|
-
'redirect_uri' => @redirect_uri,
|
|
345
|
-
'response_type' => 'code',
|
|
346
|
-
'state' => @state,
|
|
347
|
-
'scope' => @scope_list.join(' ')
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
uri.query = URI.encode_www_form(params)
|
|
351
|
-
uri.to_s
|
|
175
|
+
# 交互式判定:CI 或无 TTY 视为非交互(无法完成浏览器 SSO)
|
|
176
|
+
def interactive?
|
|
177
|
+
return false if truthy_env?(ENV['CI'])
|
|
178
|
+
$stdin.tty?
|
|
179
|
+
rescue StandardError
|
|
180
|
+
false
|
|
352
181
|
end
|
|
353
182
|
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
when /mswin|mingw|cygwin/
|
|
358
|
-
# Windows: 使用双引号包围URL避免参数解析问题
|
|
359
|
-
system("start \"\" \"#{url}\"")
|
|
360
|
-
when /darwin/
|
|
361
|
-
system("open", url)
|
|
362
|
-
when /linux|bsd/
|
|
363
|
-
system("xdg-open", url)
|
|
364
|
-
else
|
|
365
|
-
puts "无法自动打开浏览器,请手动访问: #{url}"
|
|
366
|
-
end
|
|
183
|
+
def truthy_env?(value)
|
|
184
|
+
s = value.to_s.strip.downcase
|
|
185
|
+
!s.empty? && !%w[0 false no].include?(s)
|
|
367
186
|
end
|
|
368
187
|
|
|
369
|
-
#
|
|
370
|
-
def
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
#
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
return nil
|
|
377
|
-
end
|
|
378
|
-
|
|
379
|
-
puts "启动本地服务器,监听端口 #{@server_port}..."
|
|
380
|
-
puts "提示:按 Ctrl+C 可以中断并获得更多选择"
|
|
381
|
-
puts "🔄 正在使用飞书身份登录 JPS..."
|
|
382
|
-
|
|
383
|
-
begin
|
|
384
|
-
server = WEBrick::HTTPServer.new(
|
|
385
|
-
Port: @server_port,
|
|
386
|
-
BindAddress: '127.0.0.1',
|
|
387
|
-
Logger: WEBrick::Log.new(get_null_device),
|
|
388
|
-
AccessLog: []
|
|
389
|
-
)
|
|
390
|
-
rescue Errno::EADDRINUSE
|
|
391
|
-
puts "✗ 端口 #{@server_port} 仍被占用,无法启动服务器"
|
|
392
|
-
return nil
|
|
393
|
-
rescue Errno::ENOENT
|
|
394
|
-
puts "✗ 启动服务器失败: 系统找不到指定的路径或文件"
|
|
395
|
-
return nil
|
|
396
|
-
rescue => e
|
|
397
|
-
puts "✗ 启动服务器失败: #{e.message}"
|
|
398
|
-
if RbConfig::CONFIG['host_os'] =~ /mswin|mingw|cygwin/
|
|
399
|
-
puts " 提示: 如果是Windows系统,请确保防火墙允许Ruby访问网络"
|
|
400
|
-
end
|
|
401
|
-
return nil
|
|
402
|
-
end
|
|
403
|
-
|
|
404
|
-
# 处理根路径的请求
|
|
405
|
-
server.mount_proc '/' do |req, res|
|
|
406
|
-
begin
|
|
407
|
-
# 解析请求参数
|
|
408
|
-
query_params = URI.decode_www_form(req.query_string || '').to_h
|
|
409
|
-
puts "接收到回调请求,参数: #{query_params.inspect}" if @verbose
|
|
410
|
-
|
|
411
|
-
if query_params['error']
|
|
412
|
-
puts "授权错误: #{query_params['error']}" if @verbose
|
|
413
|
-
res.content_type = "text/html; charset=UTF-8"
|
|
414
|
-
res.body = build_error_page(query_params['error'])
|
|
415
|
-
elsif query_params['code']
|
|
416
|
-
code = query_params['code']
|
|
417
|
-
# 静默处理,避免重复输出
|
|
418
|
-
res.content_type = "text/html; charset=UTF-8"
|
|
419
|
-
res.body = build_success_page
|
|
420
|
-
else
|
|
421
|
-
# 忽略没有参数的请求(如 favicon.ico 等)
|
|
422
|
-
res.content_type = "text/html; charset=UTF-8"
|
|
423
|
-
res.body = "等待授权中..."
|
|
424
|
-
end
|
|
188
|
+
# authorize 入口:redirectUri 内含 code 与 state=oidc_login
|
|
189
|
+
def build_authorize_url(code)
|
|
190
|
+
redirect_uri = "#{@web_origin}#{LOGIN_ENTRY_PATH}?" +
|
|
191
|
+
URI.encode_www_form('code' => code, 'state' => OIDC_STATE)
|
|
192
|
+
"#{@api_base}/oauth2/authorization/jps?" +
|
|
193
|
+
URI.encode_www_form('redirectUri' => redirect_uri)
|
|
194
|
+
end
|
|
425
195
|
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
196
|
+
# 连续网络硬错误达到此次数即中止轮询(区别于"服务端已应答但尚未就绪"的正常重试)
|
|
197
|
+
MAX_NET_ERRORS = 3
|
|
198
|
+
|
|
199
|
+
# 传输层硬错误(DNS / 连接拒绝 / TLS / 超时),持续出现说明连不上,应尽早失败而非空转到超时
|
|
200
|
+
NETWORK_ERRORS = [
|
|
201
|
+
SocketError, Errno::ECONNREFUSED, Errno::ECONNRESET, Errno::EHOSTUNREACH,
|
|
202
|
+
Errno::ENETUNREACH, Errno::ETIMEDOUT, Net::OpenTimeout, Net::ReadTimeout,
|
|
203
|
+
OpenSSL::SSL::SSLError
|
|
204
|
+
].freeze
|
|
205
|
+
|
|
206
|
+
# 凭 code 轮询 /api/oidc/token 取 LoginVo;就绪判定 meta.code==0 && data.token
|
|
207
|
+
def poll_login_vo(code)
|
|
208
|
+
url = URI("#{@api_base}#{OIDC_TOKEN_PATH}?" + URI.encode_www_form('code' => code))
|
|
209
|
+
deadline = Time.now + POLL_TIMEOUT
|
|
210
|
+
last_hint = ''
|
|
211
|
+
net_errors = 0
|
|
212
|
+
|
|
213
|
+
loop do
|
|
214
|
+
data, hint, net_error = poll_once(url)
|
|
215
|
+
return data if data
|
|
216
|
+
|
|
217
|
+
if net_error
|
|
218
|
+
net_errors += 1
|
|
219
|
+
if net_errors >= MAX_NET_ERRORS
|
|
220
|
+
puts "✗ 无法连接 JPS 登录服务(#{hint}),请检查网络后重试"
|
|
221
|
+
return nil
|
|
439
222
|
end
|
|
223
|
+
else
|
|
224
|
+
net_errors = 0
|
|
225
|
+
last_hint = hint unless hint.to_s.empty?
|
|
440
226
|
end
|
|
441
|
-
end
|
|
442
227
|
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
# 在线程中运行服务器,最多等待 3 分钟
|
|
447
|
-
thread = Thread.new { server.start }
|
|
448
|
-
begin
|
|
449
|
-
thread.join(180) # 最多等待 3 分钟
|
|
450
|
-
rescue => e
|
|
451
|
-
puts "服务器等待超时"
|
|
452
|
-
server.shutdown
|
|
453
|
-
ensure
|
|
454
|
-
trap('INT', previous_trap || 'DEFAULT')
|
|
455
|
-
end
|
|
456
|
-
|
|
457
|
-
code
|
|
458
|
-
end
|
|
459
|
-
|
|
460
|
-
# 确保端口可用,处理端口占用问题
|
|
461
|
-
def ensure_port_available(port)
|
|
462
|
-
return true unless port_occupied?(port)
|
|
463
|
-
|
|
464
|
-
puts "⚠️ 端口 #{port} 被占用,正在检查占用进程..."
|
|
465
|
-
|
|
466
|
-
# 获取占用端口的进程信息
|
|
467
|
-
process_info = get_port_process_info(port)
|
|
468
|
-
|
|
469
|
-
if process_info.nil?
|
|
470
|
-
puts "✗ 无法获取端口占用信息"
|
|
471
|
-
return false
|
|
472
|
-
end
|
|
473
|
-
|
|
474
|
-
puts "占用进程信息:"
|
|
475
|
-
puts " PID: #{process_info[:pid]}"
|
|
476
|
-
puts " 进程名: #{process_info[:name]}"
|
|
477
|
-
puts " 命令: #{process_info[:command]}"
|
|
478
|
-
|
|
479
|
-
# 检查是否是自己的进程(可能是之前未正常关闭的实例)
|
|
480
|
-
if process_info[:name].include?('ruby') && (process_info[:command].include?('pindo') || process_info[:command].include?('jps'))
|
|
481
|
-
puts "检测到可能是 Pindo/JPS 之前的遗留进程"
|
|
482
|
-
if ask_user_kill_process?
|
|
483
|
-
return kill_process_by_pid(process_info[:pid])
|
|
484
|
-
end
|
|
485
|
-
else
|
|
486
|
-
puts "端口被其他应用程序占用"
|
|
487
|
-
if ask_user_kill_process?
|
|
488
|
-
return kill_process_by_pid(process_info[:pid])
|
|
228
|
+
if Time.now >= deadline
|
|
229
|
+
puts "✗ 登录超时(#{POLL_TIMEOUT.to_i}s 内未完成授权,#{last_hint}),请重试"
|
|
230
|
+
return nil
|
|
489
231
|
end
|
|
232
|
+
sleep POLL_INTERVAL
|
|
490
233
|
end
|
|
491
|
-
|
|
492
|
-
return false
|
|
493
234
|
end
|
|
494
235
|
|
|
495
|
-
#
|
|
496
|
-
def
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
236
|
+
# 单次轮询。返回 [data_hash, nil, false] / [nil, hint, false(未就绪)] / [nil, hint, true(网络错)]
|
|
237
|
+
def poll_once(url)
|
|
238
|
+
http = Net::HTTP.new(url.host, url.port)
|
|
239
|
+
http.use_ssl = (url.scheme == 'https')
|
|
240
|
+
configure_ssl(http)
|
|
241
|
+
|
|
242
|
+
req = Net::HTTP::Get.new(url)
|
|
243
|
+
req['accept'] = 'application/json, text/plain, */*'
|
|
244
|
+
req['disable-snake'] = 'true'
|
|
245
|
+
req['origin'] = @web_origin
|
|
246
|
+
req['referer'] = @web_origin + '/'
|
|
247
|
+
req['user-agent'] = "jpsclient/#{JPSClient::VERSION}"
|
|
248
|
+
|
|
249
|
+
response = http.request(req)
|
|
250
|
+
# 4xx/5xx 也可能带 envelope(如尚未 bind),一律读出按 meta 判定
|
|
251
|
+
body = response.body.to_s.dup.force_encoding('UTF-8')
|
|
252
|
+
envelope = parse_json(body)
|
|
253
|
+
meta = envelope.is_a?(Hash) ? (envelope['meta'] || {}) : {}
|
|
254
|
+
data = envelope.is_a?(Hash) ? envelope['data'] : nil
|
|
255
|
+
|
|
256
|
+
if meta['code'] == 0 && data.is_a?(Hash) && !data['token'].to_s.empty?
|
|
257
|
+
return [data, nil, false]
|
|
508
258
|
end
|
|
509
|
-
end
|
|
510
259
|
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
# 获取进程详细信息
|
|
523
|
-
ps_output = `ps -p #{pid} -o pid,comm,args 2>/dev/null`.lines
|
|
524
|
-
return nil if ps_output.length < 2
|
|
525
|
-
|
|
526
|
-
process_line = ps_output[1].strip
|
|
527
|
-
parts = process_line.split(/\s+/, 3)
|
|
528
|
-
|
|
529
|
-
return {
|
|
530
|
-
pid: parts[0],
|
|
531
|
-
name: parts[1] || 'unknown',
|
|
532
|
-
command: parts[2] || 'unknown'
|
|
533
|
-
}
|
|
534
|
-
end
|
|
535
|
-
|
|
536
|
-
# Windows 使用 netstat 命令
|
|
537
|
-
if RbConfig::CONFIG['host_os'] =~ /mswin|mingw|cygwin/
|
|
538
|
-
output = `netstat -ano | findstr :#{port}`.strip
|
|
539
|
-
return nil if output.empty?
|
|
540
|
-
|
|
541
|
-
lines = output.split("\n")
|
|
542
|
-
listening_line = lines.find { |line| line.include?('LISTENING') }
|
|
543
|
-
return nil unless listening_line
|
|
544
|
-
|
|
545
|
-
pid = listening_line.split.last
|
|
546
|
-
return nil if pid.nil? || pid.empty?
|
|
547
|
-
|
|
548
|
-
# 获取进程名称
|
|
549
|
-
task_output = `tasklist /FI "PID eq #{pid}" /FO CSV /NH 2>nul`.strip
|
|
550
|
-
if !task_output.empty?
|
|
551
|
-
process_name = task_output.split(',').first.gsub('"', '')
|
|
552
|
-
return {
|
|
553
|
-
pid: pid,
|
|
554
|
-
name: process_name,
|
|
555
|
-
command: process_name
|
|
556
|
-
}
|
|
557
|
-
end
|
|
558
|
-
end
|
|
559
|
-
|
|
560
|
-
return nil
|
|
561
|
-
rescue => e
|
|
562
|
-
puts "获取进程信息时出错: #{e.message}"
|
|
563
|
-
return nil
|
|
564
|
-
end
|
|
260
|
+
hint = (meta['message'] || meta['code'] || '登录态未就绪').to_s
|
|
261
|
+
puts "[oidc] 授权尚未完成,继续等待...(服务端: #{hint})" if @verbose
|
|
262
|
+
[nil, hint, false]
|
|
263
|
+
rescue *NETWORK_ERRORS => e
|
|
264
|
+
# 网络硬错误:连续多次即中止(见 poll_login_vo)
|
|
265
|
+
puts "[oidc] 网络错误: #{e.class} - #{e.message}" if @verbose
|
|
266
|
+
[nil, "#{e.class}: #{e.message}", true]
|
|
267
|
+
rescue => e
|
|
268
|
+
# 其它异常(如解析失败)当作未就绪,继续重试
|
|
269
|
+
puts "[oidc] 轮询出错: #{e.class} - #{e.message}" if @verbose
|
|
270
|
+
[nil, e.message, false]
|
|
565
271
|
end
|
|
566
272
|
|
|
567
|
-
#
|
|
568
|
-
def
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
rescue Interrupt
|
|
578
|
-
puts "\n\n用户中断操作"
|
|
579
|
-
return false
|
|
580
|
-
rescue => e
|
|
581
|
-
puts "无法读取用户输入: #{e.message}"
|
|
582
|
-
return false
|
|
583
|
-
end
|
|
273
|
+
# LoginVo(camelCase)→ 内部字段
|
|
274
|
+
def apply_login_vo(data)
|
|
275
|
+
@access_token = data['token']
|
|
276
|
+
@username = data['username']
|
|
277
|
+
@user_id = (data['userId'] || data['user_id']).to_s
|
|
278
|
+
@permissions = data['permissions']
|
|
279
|
+
@lark_user_id = data['larkUserId'] || data['lark_user_id']
|
|
280
|
+
@tenant_manager = !!(data['tenantManager'] || data['tenant_manager'])
|
|
281
|
+
@avatar = data['avatar']
|
|
282
|
+
@token_expire_timestamp = (data['tokenExpireTimestamp'] || data['token_expire_timestamp'] || 0).to_i
|
|
584
283
|
end
|
|
585
284
|
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
begin
|
|
591
|
-
puts "正在终止进程 #{pid}..."
|
|
592
|
-
|
|
593
|
-
# 跨平台的进程终止命令
|
|
594
|
-
if RbConfig::CONFIG['host_os'] =~ /mswin|mingw|cygwin/
|
|
595
|
-
# Windows
|
|
596
|
-
success = system("taskkill /PID #{pid} /F >nul 2>&1")
|
|
597
|
-
else
|
|
598
|
-
# macOS/Linux - 先尝试优雅终止,再强制终止
|
|
599
|
-
success = system("kill -TERM #{pid} 2>/dev/null")
|
|
600
|
-
sleep(2)
|
|
601
|
-
# 检查进程是否还存在
|
|
602
|
-
if system("kill -0 #{pid} 2>/dev/null")
|
|
603
|
-
puts "优雅终止失败,使用强制终止..."
|
|
604
|
-
success = system("kill -KILL #{pid} 2>/dev/null")
|
|
605
|
-
end
|
|
606
|
-
end
|
|
607
|
-
|
|
608
|
-
if success
|
|
609
|
-
puts "✓ 进程 #{pid} 已终止"
|
|
610
|
-
sleep(1) # 等待端口释放
|
|
611
|
-
|
|
612
|
-
# 再次检查端口是否可用
|
|
613
|
-
unless port_occupied?(@server_port)
|
|
614
|
-
puts "✓ 端口 #{@server_port} 现在可用"
|
|
615
|
-
return true
|
|
616
|
-
else
|
|
617
|
-
puts "⚠️ 端口仍被占用,可能需要等待更长时间"
|
|
618
|
-
return false
|
|
619
|
-
end
|
|
620
|
-
else
|
|
621
|
-
puts "✗ 终止进程失败,可能需要管理员权限"
|
|
622
|
-
return false
|
|
623
|
-
end
|
|
624
|
-
rescue => e
|
|
625
|
-
puts "终止进程时出错: #{e.message}"
|
|
626
|
-
return false
|
|
627
|
-
end
|
|
285
|
+
def parse_json(text)
|
|
286
|
+
JSON.parse(text)
|
|
287
|
+
rescue JSON::ParserError
|
|
288
|
+
{}
|
|
628
289
|
end
|
|
629
290
|
|
|
630
|
-
#
|
|
631
|
-
def
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
<title>JPS 授权成功</title>
|
|
638
|
-
<style>
|
|
639
|
-
body { font-family: Arial, sans-serif; text-align: center; padding: 40px; background-color: #f5f5f5; }
|
|
640
|
-
.container { max-width: 600px; margin: 0 auto; background: white; padding: 40px; border-radius: 8px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); }
|
|
641
|
-
.success { color: #4CAF50; margin-bottom: 20px; }
|
|
642
|
-
.countdown { font-weight: bold; color: #FF5722; }
|
|
643
|
-
.close-btn { background: #4CAF50; color: white; border: none; padding: 10px 20px; cursor: pointer; border-radius: 4px; font-size: 16px; }
|
|
644
|
-
</style>
|
|
645
|
-
<script>
|
|
646
|
-
var secondsLeft = 3;
|
|
647
|
-
function updateCountdown() {
|
|
648
|
-
document.getElementById('countdown').innerText = secondsLeft;
|
|
649
|
-
if (secondsLeft <= 0) {
|
|
650
|
-
window.close();
|
|
651
|
-
} else {
|
|
652
|
-
secondsLeft -= 1;
|
|
653
|
-
setTimeout(updateCountdown, 1000);
|
|
654
|
-
}
|
|
655
|
-
}
|
|
656
|
-
window.onload = function() { updateCountdown(); }
|
|
657
|
-
</script>
|
|
658
|
-
</head>
|
|
659
|
-
<body>
|
|
660
|
-
<div class="container">
|
|
661
|
-
<h1 class="success">✓ JPS 授权成功!</h1>
|
|
662
|
-
<p>已获取授权码,正在返回命令行...</p>
|
|
663
|
-
<p>此窗口将在 <span id="countdown" class="countdown">3</span> 秒后自动关闭</p>
|
|
664
|
-
<button class="close-btn" onclick="window.close()">立即关闭</button>
|
|
665
|
-
</div>
|
|
666
|
-
</body>
|
|
667
|
-
</html>
|
|
668
|
-
HTML
|
|
291
|
+
# 兼容 OpenSSL 3.x(避免 CRL 检查失败)
|
|
292
|
+
def configure_ssl(http)
|
|
293
|
+
return unless http.use_ssl?
|
|
294
|
+
http.verify_mode = OpenSSL::SSL::VERIFY_PEER
|
|
295
|
+
http.cert_store = OpenSSL::X509::Store.new
|
|
296
|
+
http.cert_store.set_default_paths
|
|
297
|
+
http.cert_store.flags = 0
|
|
669
298
|
end
|
|
670
299
|
|
|
671
|
-
#
|
|
672
|
-
def
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
.close-btn { background: #f44336; color: white; border: none; padding: 10px 20px; cursor: pointer; border-radius: 4px; font-size: 16px; }
|
|
684
|
-
</style>
|
|
685
|
-
</head>
|
|
686
|
-
<body>
|
|
687
|
-
<div class="container">
|
|
688
|
-
<h1 class="error">✗ JPS 授权失败</h1>
|
|
689
|
-
<p>错误信息: #{error_msg}</p>
|
|
690
|
-
<p>请返回命令行重试</p>
|
|
691
|
-
<button class="close-btn" onclick="window.close()">关闭窗口</button>
|
|
692
|
-
</div>
|
|
693
|
-
</body>
|
|
694
|
-
</html>
|
|
695
|
-
HTML
|
|
300
|
+
# 跨平台打开浏览器
|
|
301
|
+
def open_browser(url)
|
|
302
|
+
case RbConfig::CONFIG['host_os']
|
|
303
|
+
when /mswin|mingw|cygwin/
|
|
304
|
+
system("start \"\" \"#{url}\"")
|
|
305
|
+
when /darwin/
|
|
306
|
+
system("open", url)
|
|
307
|
+
when /linux|bsd/
|
|
308
|
+
system("xdg-open", url)
|
|
309
|
+
else
|
|
310
|
+
puts "无法自动打开浏览器,请手动访问上面的链接"
|
|
311
|
+
end
|
|
696
312
|
end
|
|
697
|
-
|
|
698
313
|
end
|
|
699
314
|
|
|
700
|
-
end
|
|
315
|
+
end
|