diff --git a/config/locales/server.zh_CN.yml b/config/locales/server.zh_CN.yml index 869def8..dbc3298 100644 --- a/config/locales/server.zh_CN.yml +++ b/config/locales/server.zh_CN.yml @@ -34,6 +34,7 @@ zh_CN: oauth2_authorize_options: "授权时请求这些选项" oauth2_scope: "授权请求此范围时" oauth2_button_title: "OAuth2 按钮的文本" + oauth2_dingtalk_title: "dingtalk 按钮的文本" oauth2_allow_association_change: 允许用户从 OAuth2 提供商断开并重新连接他们的 Discourse 帐户 oauth2_disable_csrf: "禁用 CSRF 检查" errors: diff --git a/config/settings.yml b/config/settings.yml index 204925b..4a18415 100644 --- a/config/settings.yml +++ b/config/settings.yml @@ -4,6 +4,7 @@ login: client: true oauth2_client_id: "" oauth2_client_secret: "" + oauth2_client_corpid: "" oauth2_authorize_url: "" oauth2_authorize_signup_url: "" oauth2_token_url: "" @@ -43,11 +44,20 @@ login: default: "scope" type: list oauth2_scope: "" + oauth2_json_unionid_path: "unionid" oauth2_button_title: default: "with OAuth2" client: true + oauth2_dingtalk_title: + default: "with DingTalk" + client: true oauth2_allow_association_change: default: false oauth2_disable_csrf: default: false - hidden: true + hidden: false + oauth2_email_domain: + default: "tmp.renogy.com" + client: true + dingtalk_enable_cache: + default: false \ No newline at end of file diff --git a/lib/dingtalk_authenticator.rb b/lib/dingtalk_authenticator.rb new file mode 100644 index 0000000..5f00196 --- /dev/null +++ b/lib/dingtalk_authenticator.rb @@ -0,0 +1,246 @@ +# dingtalk_authenticator.rb +class DingtalkAuthenticator < OAuth2BasicAuthenticator + + + # def register_middleware(omniauth) + # omniauth.provider :oauth2_basic, + # name: name, + # setup: lambda { |env| + # opts = env["omniauth.strategy"].options + # + # # # 强制启用 Faraday 日志中间件 + # # opts[:client_options][:connection_build] = lambda do |builder| + # # if SiteSetting.oauth2_debug_auth + # # builder.response :logger, Rails.logger, { + # # bodies: true, + # # formatter: OAuth2FaradayFormatter # 使用自定义格式化类 + # # } + # # end + # # builder.adapter FinalDestination::FaradayAdapter + # # end + # + # # 强制使用钉钉参数命名 + # opts[:client_id] = SiteSetting.oauth2_client_id # 使用独立配置项 + # opts[:client_secret] = SiteSetting.oauth2_client_secret + # + # opts[:client_options] = { + # site: "https://api.dingtalk.com", + # authorize_url: "/oauth2/auth", + # token_url: "/v1.0/oauth2/userAccessToken", + # auth_scheme: :request_body + # } + # + # opts[:token_params] = { + # headers: { + # "Content-Type" => "application/json", + # "X-Dingtalk-Isv" => "true" + # }, + # body: { + # clientId: opts[:client_id], # 使用驼峰命名 + # clientSecret: opts[:client_secret], + # code: env.dig("rack.request.query_hash", "code"), # 安全访问 + # grantType: "authorization_code" + # }.to_json + # } + # } + # end + # 核心认证流程 + def after_authenticate(auth, existing_account: nil) + # 直接使用策略类获取的 token + user_token = auth.dig(:credentials, :token) + return auth_failed("Token缺失") unless user_token + + # 2. 获取基础用户信息(包含unionId) + base_info = get_base_user_info(user_token) + return auth_failed("基础信息获取失败") unless base_info + unionid = base_info.dig("unionId") + + log "[钉钉] after_authenticate: 获取详细用户信息" + # 3. 获取详细用户信息 + user_details = fetch_user_details(unionid) + return auth_failed("用户详情获取失败") unless user_details + + # 4. 构建认证结果 + build_auth_result(user_details).tap do |result| + log "[钉钉] 认证完成: #{result.inspect}" + end + end + + private + + # 获取用户访问令牌 + def get_user_access_token(code) + log "[HTTP] 获取用户访问令牌 | code: #{code[0..3]}***" + + response = Faraday.post( + "https://api.dingtalk.com/v1.0/oauth2/userAccessToken", + { + clientId: SiteSetting.oauth2_client_id, + clientSecret: SiteSetting.oauth2_client_secret, + code: code, + grantType: "authorization_code" + }.to_json, + { + "Content-Type" => "application/json", + "Accept" => "application/json" + } + ) + + log_response(response, "用户令牌") + JSON.parse(response.body)["accessToken"] rescue nil + end + + # 获取基础用户信息 + def get_base_user_info(user_token) + log "[HTTP] 获取基础用户信息" + + return nil unless user_token + + response = Faraday.get( + "https://api.dingtalk.com/v1.0/contact/users/me", + nil, + { "x-acs-dingtalk-access-token" => user_token } + ) + + return log_failure("请求失败: #{response.status}") unless response.success? + + JSON.parse(response.body) rescue nil + end + + # 获取详细用户信息(带缓存控制) + def fetch_user_details(unionid) + if SiteSetting.dingtalk_enable_cache + fetch_with_cache(unionid) + else + fetch_without_cache(unionid) + end + end + + # 带缓存版本 + def fetch_with_cache(unionid) + cache_key = "dingtalk_user_#{unionid}" + + Discourse.cache.fetch(cache_key, expires_in: 3600) do + log "[缓存] 用户详情缓存未命中,重新获取" + fetch_corp_details(unionid) + end + end + + # 无缓存版本 + def fetch_without_cache(unionid) + fetch_corp_details(unionid) + end + + # 企业API获取详细信息 + def fetch_corp_details(unionid) + log "[钉钉] fetch_corp_details" + # 获取企业令牌 + corp_token = get_corp_access_token + return log_failure("企业令牌获取失败") unless corp_token + + # 通过unionid获取userid + userid = get_userid(corp_token, unionid) + return log_failure("UserID查询失败") unless userid + + # 获取完整用户信息(钉钉文档要求access_token作为查询参数,请求体包含userid) + url = "https://oapi.dingtalk.com/topapi/v2/user/get?access_token=#{corp_token}" + + response = Faraday.post( + url, + { + userid: userid, + language: "zh_CN" # 根据需求添加语言参数 + }.to_json, + { + "Content-Type" => "application/json", + "Accept" => "application/json" + } + ) + + return log_failure("用户详情请求失败") unless response.success? + + parse_details(JSON.parse(response.body).dig("result")) # 注意提取result字段 + end + + # 获取企业访问令牌(带缓存) + def get_corp_access_token + cache_key = "dingtalk_corp_token_#{SiteSetting.oauth2_client_id}" + + Discourse.cache.fetch(cache_key, expires_in: 7100) do + log "[缓存] 企业令牌缓存未命中,重新获取" + response = Faraday.get( + "https://oapi.dingtalk.com/gettoken", + { + appkey: SiteSetting.oauth2_client_id, + appsecret: SiteSetting.oauth2_client_secret + }, + {} + ) + + log_response(response, "企业令牌接口") + JSON.parse(response.body)["access_token"] rescue nil # 注意字段名可能是 "access_token" + end + end + + # 通过unionid获取userid + def get_userid(corp_token, unionid) + log "通过unionid获取userid 开始" + + # 钉钉文档要求:access_token作为查询参数,unionid在请求体中 + url = "https://oapi.dingtalk.com/topapi/user/getbyunionid?access_token=#{corp_token}" + + response = Faraday.post( + url, + { unionid: unionid }.to_json, + { + "Content-Type" => "application/json", + "Accept" => "application/json" + } + ) + + # 解析响应 + JSON.parse(response.body).dig("result", "userid") rescue nil + end + + # 解析用户详情 + def parse_details(data) + return {} unless data.is_a?(Hash) + + { + user_id: data["unionid"] || data["unionId"], # 兼容大小写 + username: data["name"] || "钉钉用户", + email: data["email"] || "#{data['unionid']}@#{SiteSetting.oauth2_email_domain}", + avatar: data["avatarUrl"] || data["avatar"], # 钉钉可能返回 avatarUrl + email_verified: data["email"].present? + }.compact + end + + # 构建认证结果 + def build_auth_result(details) + Auth::Result.new.tap do |result| + result.email = details[:email] + result.email_valid = details[:email_verified] + result.username = details[:username] + result.name = details[:name] + result.extra_data = { unionid: details[:user_id] } + end + end + + # 统一日志方法 + def log_response(response, api_name) + log <<~LOG + [API响应] #{api_name} + 状态码: #{response.status} + 响应头: #{response.headers.to_json} + 响应体: #{response.body[0..200]}... + LOG + end + + def auth_failed(reason) + log "[错误] 认证失败: #{reason}" + result = Auth::Result.new + result.failed = true + result.failed_reason = reason + result + end +end \ No newline at end of file diff --git a/lib/oauth2_basic_authenticator.rb b/lib/oauth2_basic_authenticator.rb index 0b77def..5f03671 100644 --- a/lib/oauth2_basic_authenticator.rb +++ b/lib/oauth2_basic_authenticator.rb @@ -26,6 +26,9 @@ def register_middleware(omniauth) authorize_url: SiteSetting.oauth2_authorize_url, token_url: SiteSetting.oauth2_token_url, token_method: SiteSetting.oauth2_token_url_method.downcase.to_sym, + authorize_params: { + prompt: "consent" # 强制要求用户授权时显示权限确认界面 + } } opts[:authorize_options] = SiteSetting .oauth2_authorize_options @@ -47,8 +50,15 @@ def register_middleware(omniauth) opts[:client_options][:auth_scheme] = :request_body opts[:token_params] = { headers: { - "Authorization" => basic_auth_header, + "Content-Type" => "application/json", + "Accept" => "application/json" }, + body: { + clientId: opts[:client_id], + clientSecret: opts[:client_secret], + code: env["rack.request.query_hash"]["code"], + grantType: "authorization_code" + }.to_json } elsif SiteSetting.oauth2_send_auth_header? opts[:client_options][:auth_scheme] = :basic_auth diff --git a/lib/omniauth/strategies/oauth2_basic.rb b/lib/omniauth/strategies/oauth2_basic.rb index d493c8a..fb5e66c 100644 --- a/lib/omniauth/strategies/oauth2_basic.rb +++ b/lib/omniauth/strategies/oauth2_basic.rb @@ -3,6 +3,90 @@ class OmniAuth::Strategies::Oauth2Basic < ::OmniAuth::Strategies::OAuth2 option :name, "oauth2_basic" + # 禁用默认参数编码 + option :token_params, { + parse: :json + } + + def build_access_token + verifier = request.params['code'] + + # 钉钉要求参数命名规范(驼峰式) + raw_body = { + clientId: client.id, # 注意首字母大写 + clientSecret: client.secret, + code: verifier, + grantType: "authorization_code" + }.to_json + + # 强制JSON请求头和格式 + response = client.request(:post, client.token_url, { + body: raw_body, + headers: { + "Content-Type" => "application/json", + "Accept" => "application/json" + } + }) + + # 调试日志(打印实际发送内容) + Rails.logger.info "钉钉Token请求体: #{raw_body}" + Rails.logger.info "钉钉Token响应: #{response.body}" + + # 解析钉钉响应 + token_data = JSON.parse(response.body) + ::OAuth2::AccessToken.new( + client, + token_data['accessToken'], + expires_in: token_data['expireIn'] + ) + end + + # 用户ID映射(使用unionId) + uid do + raw_info[:unionId] || raw_info["unionId"] || raw_info[:openId] || "unknown" + end + + # 用户信息映射 + info do + { + name: raw_info[:nick] || raw_info["nick"] || "钉钉用户", + email: raw_info[:email] || raw_info["email"] || "#{raw_info[:unionId]}@dingtalk.fallback", + image: raw_info[:avatarUrl] || raw_info["avatarUrl"] + } + end + + # 获取钉钉用户信息(修复请求头) + def raw_info + @raw_info ||= begin + + user_info_url = SiteSetting.oauth2_user_json_url + + conn = Faraday.new( + url: user_info_url, + headers: { + 'x-acs-dingtalk-access-token' => access_token.token, + 'x-acs-dingtalk-org-id' => SiteSetting.oauth2_client_corpid, + 'Accept' => 'application/json' + } + ) do |f| + f.request :json + f.response :json + end + + # 添加调试日志 + Rails.logger.info "用户信息接口URL: #{user_info_url}" + Rails.logger.info "请求头: #{conn.headers}" + + response = conn.get("") + raise StandardError, response.body['message'] if response.status != 200 + + response.body.deep_symbolize_keys + rescue => e + Rails.logger.error "钉钉用户信息获取失败: #{e.message}" + {} + end + end + uid do if path = SiteSetting.oauth2_callback_user_id_path.split(".") recurse(access_token, [*path]) if path.present? diff --git a/plugin.rb b/plugin.rb index e0e2d31..0211a57 100644 --- a/plugin.rb +++ b/plugin.rb @@ -12,6 +12,7 @@ require_relative "lib/omniauth/strategies/oauth2_basic" require_relative "lib/oauth2_faraday_formatter" require_relative "lib/oauth2_basic_authenticator" +require_relative "lib/dingtalk_authenticator.rb" # You should use this register if you want to add custom paths to traverse the user details JSON. # We'll store the value in the user associated account's extra attribute hash using the full path as the key. @@ -29,6 +30,10 @@ # }, self) DiscoursePluginRegistry.define_filtered_register :oauth2_basic_required_json_paths -auth_provider title_setting: "oauth2_button_title", authenticator: OAuth2BasicAuthenticator.new +# auth_provider title_setting: "oauth2_button_title", authenticator: OAuth2BasicAuthenticator.new require_relative "lib/validators/oauth2_basic/oauth2_fetch_user_details_validator" + + +auth_provider title_setting: "oauth2_dingtalk_title", authenticator: DingtalkAuthenticator.new +