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.
@@ -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 = 'client_login' # 仅此项可有默认值
31
- @server_port = 8898 # 仅此项可有默认值
32
- @aes_key = nil # AES 加密密钥,必需配置
33
- @token_dir = nil # Token 存储目录,可选配置
34
- @token_file_name = nil # Token 文件名,可选配置
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'] # 加载 token 目录配置
53
- config.token_file_name = data['token_file_name'] if data['token_file_name'] # 加载 token 文件名配置
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
- @client_id = @config.client_id
75
- @feishu_auth_url = @config.feishu_auth_url
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
- result = authorize_and_login
118
-
119
- # 如果用户主动取消,返回特殊标识
120
- return :user_cancelled if result == :user_cancelled
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 数据(供 Client 使用)
123
+ # 获取 token 数据(供 Token#save 使用,内部 snake 键)
136
124
  def get_token_data
137
125
  return nil unless @access_token
138
126
 
139
127
  {
140
- 'token' => @access_token,
141
- 'username' => @username,
142
- 'user_id' => @user_id,
143
- 'permissions' => @permissions,
144
- 'lark_user_id' => @lark_user_id,
145
- 'tenant_manager' => @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
- if code
248
- if exchange_code_for_token(code)
249
- puts "✓ JPS 登录成功!用户名: #{@username}"
250
- return true
251
- end
252
- return false
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
- return false
318
- rescue => e
319
- puts "请求 JPS API 失败: #{e.class} - #{e.message}"
320
- return false
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
- end
162
+ puts ""
323
163
 
164
+ puts "🔄 正在等待浏览器完成授权(请在弹出的页面用飞书扫码/授权)..."
165
+ data = poll_login_vo(code)
166
+ return false unless data
324
167
 
325
- private
168
+ apply_login_vo(data)
169
+ return false if @access_token.to_s.empty?
326
170
 
327
- # 获取跨平台的 null 设备路径
328
- def get_null_device
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
- # 构建授权URI
339
- def build_authorization_uri
340
- uri = URI(@feishu_auth_url)
341
-
342
- params = {
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
- # 在浏览器中打开URL
355
- def open_browser(url)
356
- case RbConfig::CONFIG['host_os']
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 start_callback_server
371
- code = nil
372
-
373
- # 检查并处理端口占用
374
- unless ensure_port_available(@server_port)
375
- puts "✗ 无法使用端口 #{@server_port},登录失败"
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
- # 4秒后关闭服务器
427
- Thread.new do
428
- sleep 4
429
- server.shutdown
430
- end
431
- rescue => e
432
- puts "处理请求时出错: #{e.class} - #{e.message}" if @verbose
433
- res.content_type = "text/html; charset=UTF-8"
434
- res.body = build_error_page("处理请求时出错")
435
-
436
- Thread.new do
437
- sleep 4
438
- server.shutdown
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
- # 捕获 Ctrl+C,结束后恢复默认行为
444
- previous_trap = trap('INT') { server.shutdown }
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 port_occupied?(port)
497
- require 'socket'
498
-
499
- begin
500
- server = TCPServer.new('127.0.0.1', port)
501
- server.close
502
- false # 端口未被占用
503
- rescue Errno::EADDRINUSE
504
- true # 端口被占用
505
- rescue => e
506
- puts "⚠️ 检查端口时出错: #{e.message}"
507
- true # 出错时假设端口被占用,避免冲突
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
- def get_port_process_info(port)
513
- begin
514
- # macOS/Linux 使用 lsof 命令
515
- if system('which lsof > /dev/null 2>&1')
516
- output = `lsof -ti :#{port} 2>/dev/null`.strip
517
- return nil if output.empty?
518
-
519
- pid = output.split("\n").first
520
- return nil if pid.nil? || pid.empty?
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 ask_user_kill_process?
569
- puts "\n是否终止占用端口的进程?"
570
- puts " y/yes - 终止进程并继续 (可能影响其他应用)"
571
- puts " n/no - 取消登录 (默认)"
572
- print "> "
573
-
574
- begin
575
- response = STDIN.gets&.chomp&.downcase
576
- return ['y', 'yes'].include?(response)
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
- # 根据 PID 终止进程
587
- def kill_process_by_pid(pid)
588
- return false if pid.nil? || pid.empty?
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 build_success_page
632
- <<~HTML
633
- <!DOCTYPE html>
634
- <html>
635
- <head>
636
- <meta charset="UTF-8">
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 build_error_page(error_msg)
673
- <<~HTML
674
- <!DOCTYPE html>
675
- <html>
676
- <head>
677
- <meta charset="UTF-8">
678
- <title>JPS 授权失败</title>
679
- <style>
680
- body { font-family: Arial, sans-serif; text-align: center; padding: 40px; background-color: #f5f5f5; }
681
- .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); }
682
- .error { color: #f44336; margin-bottom: 20px; }
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