From 68386a12bb421b7fabf69f1303c8869da8218f49 Mon Sep 17 00:00:00 2001 From: mnianqi Date: Mon, 17 Aug 2026 11:03:12 +0800 Subject: [PATCH 1/5] feat: TAP-12033 support sso login --- apps/daas/src/i18n/langs/en.js | 109 +++++++- apps/daas/src/i18n/langs/zh-CN.js | 99 +++++++ apps/daas/src/i18n/langs/zh-TW.js | 99 +++++++ apps/daas/src/router/index.ts | 1 + apps/daas/src/router/routes.ts | 8 + apps/daas/src/utils/util.ts | 14 +- apps/daas/src/views/login/Login.vue | 26 ++ apps/daas/src/views/login/SsoCallback.vue | 118 ++++++++ apps/daas/src/views/setting/Setting.vue | 165 +++++++++++- apps/daas/src/views/user/List.vue | 15 ++ .../src/views/user/SsoUserImportDialog.vue | 251 ++++++++++++++++++ packages/api/src/core/sso.ts | 163 ++++++++++++ 12 files changed, 1063 insertions(+), 5 deletions(-) create mode 100644 apps/daas/src/views/login/SsoCallback.vue create mode 100644 apps/daas/src/views/user/SsoUserImportDialog.vue create mode 100644 packages/api/src/core/sso.ts diff --git a/apps/daas/src/i18n/langs/en.js b/apps/daas/src/i18n/langs/en.js index 893cdcba7..ef2e02de2 100644 --- a/apps/daas/src/i18n/langs/en.js +++ b/apps/daas/src/i18n/langs/en.js @@ -868,6 +868,81 @@ export default { 'The full Distinguished Name (DN) of the user for authentication, i.e., the identity used to log in to the AD server, e.g., user@example.com', setting_Ldap_Bind_Password_doc: 'The password corresponding to the Bind DN, used for authentication', + setting_SAML: 'SAML Authentication', + setting_SAML_Login_Enable: 'Use SAML Login', + setting_SP_Entity_ID: 'SP Entity ID', + setting_SP_ACS_URL: 'SP ACS URL', + setting_SP_Certificate: 'SP Certificate', + setting_IdP_Entity_ID: 'IdP Entity ID', + setting_IdP_SSO_URL: 'IdP SSO URL', + setting_IdP_SLO_URL: 'IdP SLO URL', + setting_IdP_Signing_Certificate: 'IdP Signing Certificate', + setting_NameID_Format: 'NameID Format', + setting_Want_Assertions_Signed: 'Require Signed Assertions', + setting_Sign_AuthnRequest: 'Sign AuthnRequest', + setting_Signature_Algorithm: 'Signature Algorithm', + setting_Clock_Skew_Seconds: 'Clock Skew (seconds)', + setting_Claim_Username: 'Username Attribute', + setting_Claim_Email: 'Email Attribute', + setting_Claim_Display_Name: 'Display Name Attribute', + setting_Claim_Groups: 'Groups Attribute', + setting_IdP_Initiated_Enabled: 'Allow IdP-Initiated SSO', + setting_JIT_Provisioning_Enabled: 'Enable JIT Provisioning', + setting_Login_Redirect_URL: 'Post-Login Redirect URL', + setting_Saml_Login_Enable_doc: + 'Enable SAML 2.0 single sign-on login', + setting_Saml_Sp_Entity_Id_doc: + 'Service Provider unique identifier. Recommended to use the ACS base URL, e.g. https://tapdata.example.com/api/sso/saml', + setting_Saml_Sp_Acs_Url_doc: + 'Assertion Consumer Service URL that receives the SAML response. Fixed as https:///api/sso/saml/acs', + setting_Saml_Sp_Private_Key_doc: + 'Service Provider private key (encrypted at rest, write-only). Generated together with the SP certificate via "Generate Key Pair".', + setting_Saml_Sp_Certificate_doc: + 'Service Provider certificate (public key) in PEM format, included in the exported SP metadata. Generate it via "Generate Key Pair".', + setting_Saml_Idp_Entity_Id_doc: + 'Identity Provider Entity ID. Auto-filled by "Import IdP Metadata".', + setting_Saml_Idp_Sso_Url_doc: + 'Identity Provider SSO (login) URL. ADFS example: https://adfs.example.com/adfs/ls/', + setting_Saml_Idp_Slo_Url_doc: + 'Identity Provider Single Logout (SLO) URL. ADFS example: https://adfs.example.com/adfs/ls/?wa=wsignout1.0', + setting_Saml_Idp_Signing_Certificate_doc: + 'Identity Provider signing certificate used to verify assertions. Auto-filled by "Import IdP Metadata".', + setting_Saml_Name_Id_Format_doc: + 'SAML NameID format; leave blank for the default. Example: urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress', + setting_Saml_Want_Assertions_Signed_doc: + 'Require the IdP to sign assertions; TapData verifies the signature with the IdP signing certificate and rejects unsigned/tampered assertions. ADFS signs by default, keep this on.', + setting_Saml_Sign_Authn_Request_doc: + 'Sign the SP-generated AuthnRequest with the SP private key. Enable only when the IdP requires SP-signed requests.', + setting_Saml_Signature_Algorithm_doc: + 'XML signature algorithm; leave blank for the default (rsa-sha256). Avoid weak algorithms such as SHA-1/MD5.', + setting_Saml_Clock_Skew_Seconds_doc: + 'Allowed clock skew (seconds) when validating assertion time conditions. Default 120; ensure NTP sync across hosts.', + setting_Saml_Claim_Username_doc: + 'SAML attribute mapped to the username. ADFS example: http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name', + setting_Saml_Claim_Email_doc: + 'SAML attribute mapped to the email. ADFS example: http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress', + setting_Saml_Claim_Display_Name_doc: + 'SAML attribute mapped to the display name. ADFS example: http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname', + setting_Saml_Claim_Groups_doc: + 'SAML attribute mapped to the user groups. ADFS example: http://schemas.xmlsoap.org/claims/Group', + setting_Saml_Idp_Initiated_Enabled_doc: + 'Allow IdP-initiated SSO (disabled by default for security)', + setting_Saml_Jit_Provisioning_Enabled_doc: + 'Just-in-time provisioning: auto-create users on first SSO login (disabled by default)', + setting_Saml_Login_Redirect_Url_doc: + 'Post-login redirect URL after a successful SSO login', + setting_saml_generate_keypair: 'Generate Key Pair', + setting_saml_export_sp_metadata: 'Export SP Metadata', + setting_saml_import_idp_metadata: 'Import IdP Metadata', + setting_saml_import_idp_metadata_label: 'Upload the IdP metadata XML file', + setting_saml_import_idp_metadata_placeholder: + 'Or paste the IdP metadata XML here', + setting_saml_keypair_generated: + 'Key pair generated. The private key is stored encrypted; click Save to persist the certificate.', + setting_saml_import_success: + 'IdP metadata parsed. IdP fields have been filled; click Save to persist.', + setting_saml_import_empty: + 'Please upload or paste the IdP metadata XML first', setting_Appearance: 'Appearance', setting_enableEnvTag: 'Environment Tag', setting_envTagContent: 'Custom Text', @@ -897,6 +972,33 @@ export default { user_list_freeze_error: 'Freeze failed', user_list_check_success: 'Pass the check', user_list_check_error: 'Check failed', + user_import_batch_import: 'Batch Import', + user_import_dialog_title: 'Batch Import Users', + user_import_dialog_tip: + 'Download the template, fill in the user list, then upload to validate and import.', + user_import_download_template: 'Download Template', + user_import_template_download_failed: 'Failed to download the template', + user_import_select_file: 'Please select a file first', + user_import_mode: 'On duplicate user', + user_import_mode_skip: 'Skip', + user_import_mode_update: 'Update', + user_import_validate: 'Validate', + user_import_confirm: 'Confirm Import', + user_import_preview_title: 'Validation result', + user_import_count_total: 'Total {count}', + user_import_count_create: 'Create {count}', + user_import_count_update: 'Update {count}', + user_import_count_skip: 'Skip {count}', + user_import_count_failed: 'Failed {count}', + user_import_col_row: 'Row', + user_import_col_status: 'Status', + user_import_col_message: 'Message', + user_import_status_CREATE: 'Create', + user_import_status_UPDATE: 'Update', + user_import_status_SKIP: 'Skip', + user_import_status_FAILED: 'Failed', + user_import_result_summary: + 'Import finished: created {create}, updated {update}, skipped {skip}, failed {failed}', user_status_notVerified: 'Not Verified', user_status_notActivated: 'Not Activated', user_status_activated: 'Activated', @@ -1295,6 +1397,9 @@ export default { 'Any requirement, please contact us by scanning WeChat QR below.', app_signIn_slogan: 'Use your data, as easy as water from tap', app_signIn_signIn: 'Sign in', + app_signIn_samlLogin: 'Single Sign-On (SAML)', + app_signIn_ssoProcessing: 'Signing you in...', + app_signIn_ssoFailed: 'Single sign-on failed. Please try again.', app_signIn_keepSignIn: 'Keep signed in', app_signIn_email_placeholder: 'Enter your email', login_email_and_ad_placeholder: 'Enter your email/LDAP username', @@ -2407,13 +2512,13 @@ export default { webhook_server_url_empty: 'Please enter the server URL', webhook_server_url_error: 'Please enter a valid server URL', webhook_custom_template: 'Custom Template', - webhook_custom_template_tip: `{'{'} + webhook_custom_template_tip: `{'{'} "action": "TaskAlter", "hookId": "\${'{'}hookId{'}'}", "actionTime": "\${'{'}actionTime{'}'}", "title": "\${'{'}title{'}'}", "content": "\${'{'}content{'}'}", - "actionData": {'{'} + "actionData": {'{'} "status": "\${'{'}actionData.status{'}'}", // Indicates the current alarm status: ING (ongoing), RECOVER (recovered), CLOSE (closed) "statusTxt": "\${'{'}actionData.statusTxt{'}'}", // Indicates the current alarm status text: ongoing, recovered, closed "level": "\${'{'}actionData.level{'}'}", // Alarm level: RECOVERY, NORMAL, WARNING, CRITICAL, EMERGENCY, ERROR, WARN, INFO diff --git a/apps/daas/src/i18n/langs/zh-CN.js b/apps/daas/src/i18n/langs/zh-CN.js index 689763963..6875a442d 100644 --- a/apps/daas/src/i18n/langs/zh-CN.js +++ b/apps/daas/src/i18n/langs/zh-CN.js @@ -814,6 +814,76 @@ export default { setting_Ldap_Bind_DN_doc: '用于进行身份验证的用户的完整Distinguished Name (DN),即登录AD服务器的身份,示例:user@example.com', setting_Ldap_Bind_Password_doc: '与Bind DN对应的用户密码,用于身份验证', + setting_SAML: 'SAML登录设置', + setting_SAML_Login_Enable: '使用SAML登录', + setting_SP_Entity_ID: 'SP Entity ID', + setting_SP_ACS_URL: 'SP ACS 地址', + setting_SP_Certificate: 'SP 证书', + setting_IdP_Entity_ID: 'IdP Entity ID', + setting_IdP_SSO_URL: 'IdP SSO 地址', + setting_IdP_SLO_URL: 'IdP SLO 地址', + setting_IdP_Signing_Certificate: 'IdP 签名证书', + setting_NameID_Format: 'NameID 格式', + setting_Want_Assertions_Signed: '要求断言签名', + setting_Sign_AuthnRequest: '签名 AuthnRequest', + setting_Signature_Algorithm: '签名算法', + setting_Clock_Skew_Seconds: '时钟偏差(秒)', + setting_Claim_Username: '用户名属性', + setting_Claim_Email: '邮箱属性', + setting_Claim_Display_Name: '显示名属性', + setting_Claim_Groups: '用户组属性', + setting_IdP_Initiated_Enabled: '允许 IdP 发起的 SSO', + setting_JIT_Provisioning_Enabled: '启用即时(JIT)预配', + setting_Login_Redirect_URL: '登录后跳转地址', + setting_Saml_Login_Enable_doc: '启用 SAML 2.0 单点登录', + setting_Saml_Sp_Entity_Id_doc: + 'SP 唯一标识,建议用 ACS 的基地址。示例:https://tapdata.example.com/api/sso/saml', + setting_Saml_Sp_Acs_Url_doc: + 'IdP 回发断言的地址,固定为 https://<你的域名>/api/sso/saml/acs', + setting_Saml_Sp_Private_Key_doc: + '服务提供方私钥(静态加密存储,只写),与 SP 证书通过「生成密钥对」成对生成', + setting_Saml_Sp_Certificate_doc: + 'SP 证书(公钥,PEM),会被打包进导出的 SP 元数据。请通过「生成密钥对」生成', + setting_Saml_Idp_Entity_Id_doc: '身份提供方(IdP)实体 ID,由「导入 IdP 元数据」自动填充', + setting_Saml_Idp_Sso_Url_doc: + '身份提供方 SSO(登录)地址。ADFS 示例:https://adfs.example.com/adfs/ls/', + setting_Saml_Idp_Slo_Url_doc: + '身份提供方单点登出(SLO)地址。ADFS 示例:https://adfs.example.com/adfs/ls/?wa=wsignout1.0', + setting_Saml_Idp_Signing_Certificate_doc: + '身份提供方签名证书,用于验证断言签名,由「导入 IdP 元数据」自动填充', + setting_Saml_Name_Id_Format_doc: + 'SAML NameID 格式;留空用默认。示例:urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress', + setting_Saml_Want_Assertions_Signed_doc: + '要求 IdP 对断言签名;TapData 会用 IdP 签名证书验签,签名不过即拒登,防止伪造/篡改。ADFS 默认签名,建议保持开启', + setting_Saml_Sign_Authn_Request_doc: + '用 SP 私钥对发给 IdP 的登录请求(AuthnRequest)签名;仅当 IdP 要求 SP 签名请求时开启', + setting_Saml_Signature_Algorithm_doc: + 'XML 签名算法;留空用默认(rsa-sha256)。避免使用 SHA-1/MD5 等弱算法', + setting_Saml_Clock_Skew_Seconds_doc: + '校验断言时间条件时允许的时钟偏差(秒),默认 120;跨机注意 NTP 同步', + setting_Saml_Claim_Username_doc: + '映射为用户名的 SAML 属性名。ADFS 示例:http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name', + setting_Saml_Claim_Email_doc: + '映射为邮箱的 SAML 属性名。ADFS 示例:http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress', + setting_Saml_Claim_Display_Name_doc: + '映射为显示名的 SAML 属性名。ADFS 示例:http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname', + setting_Saml_Claim_Groups_doc: + '映射为用户组的 SAML 属性名。ADFS 示例:http://schemas.xmlsoap.org/claims/Group', + setting_Saml_Idp_Initiated_Enabled_doc: + '允许 IdP 发起的 SSO(出于安全考虑默认关闭)', + setting_Saml_Jit_Provisioning_Enabled_doc: + '即时(JIT)预配:首次 SSO 登录时自动创建用户(默认关闭)', + setting_Saml_Login_Redirect_Url_doc: 'SSO 登录成功后的跳转地址', + setting_saml_generate_keypair: '生成密钥对', + setting_saml_export_sp_metadata: '导出 SP 元数据', + setting_saml_import_idp_metadata: '导入 IdP 元数据', + setting_saml_import_idp_metadata_label: '上传 IdP 元数据 XML 文件', + setting_saml_import_idp_metadata_placeholder: '或在此粘贴 IdP 元数据 XML', + setting_saml_keypair_generated: + '密钥对已生成,私钥已加密存储;点击「保存」以持久化证书', + setting_saml_import_success: + 'IdP 元数据已解析,IdP 字段已回填;点击「保存」以持久化', + setting_saml_import_empty: '请先上传或粘贴 IdP 元数据 XML', setting_Appearance: '外观', setting_enableEnvTag: '环境标签', setting_envTagContent: '自定义文字', @@ -843,6 +913,32 @@ export default { user_list_freeze_error: '冻结失败', user_list_check_success: '通过校验', user_list_check_error: '校验失败', + user_import_batch_import: '批量导入', + user_import_dialog_title: '批量导入用户', + user_import_dialog_tip: '下载模板并填写用户列表,然后上传进行校验和导入。', + user_import_download_template: '下载模板', + user_import_template_download_failed: '下载模板失败', + user_import_select_file: '请先选择文件', + user_import_mode: '用户已存在时', + user_import_mode_skip: '跳过', + user_import_mode_update: '更新', + user_import_validate: '校验', + user_import_confirm: '确认导入', + user_import_preview_title: '校验结果', + user_import_count_total: '共 {count}', + user_import_count_create: '新建 {count}', + user_import_count_update: '更新 {count}', + user_import_count_skip: '跳过 {count}', + user_import_count_failed: '失败 {count}', + user_import_col_row: '行号', + user_import_col_status: '状态', + user_import_col_message: '说明', + user_import_status_CREATE: '新建', + user_import_status_UPDATE: '更新', + user_import_status_SKIP: '跳过', + user_import_status_FAILED: '失败', + user_import_result_summary: + '导入完成:新建 {create},更新 {update},跳过 {skip},失败 {failed}', user_status_notVerified: '未验证', user_status_notActivated: '未激活', user_status_activated: '已激活', @@ -1222,6 +1318,9 @@ export default { '请扫描下方企业微信二维码, 联系我们获取支持', app_signIn_slogan: '像自来水一样方便地使用您的数据', app_signIn_signIn: '登录', + app_signIn_samlLogin: '单点登录 (SAML)', + app_signIn_ssoProcessing: '正在登录...', + app_signIn_ssoFailed: '单点登录失败,请重试。', app_signIn_keepSignIn: '保持登录状态', app_signIn_email_placeholder: '请输入邮箱', login_email_and_ad_placeholder: '请输入邮箱/LDAP 用户名', diff --git a/apps/daas/src/i18n/langs/zh-TW.js b/apps/daas/src/i18n/langs/zh-TW.js index d15a6d73d..f624076df 100644 --- a/apps/daas/src/i18n/langs/zh-TW.js +++ b/apps/daas/src/i18n/langs/zh-TW.js @@ -810,6 +810,76 @@ export default { setting_Ldap_Bind_DN_doc: '用於進行身份驗證的用戶完整Distinguished Name (DN),即登入AD伺服器的身份,範例:user@example.com', setting_Ldap_Bind_Password_doc: '與Bind DN對應的用戶密碼,用於身份驗證', + setting_SAML: 'SAML登入設置', + setting_SAML_Login_Enable: '使用SAML登入', + setting_SP_Entity_ID: 'SP Entity ID', + setting_SP_ACS_URL: 'SP ACS 地址', + setting_SP_Certificate: 'SP 憑證', + setting_IdP_Entity_ID: 'IdP Entity ID', + setting_IdP_SSO_URL: 'IdP SSO 地址', + setting_IdP_SLO_URL: 'IdP SLO 地址', + setting_IdP_Signing_Certificate: 'IdP 簽名憑證', + setting_NameID_Format: 'NameID 格式', + setting_Want_Assertions_Signed: '要求斷言簽名', + setting_Sign_AuthnRequest: '簽名 AuthnRequest', + setting_Signature_Algorithm: '簽名演算法', + setting_Clock_Skew_Seconds: '時鐘偏差(秒)', + setting_Claim_Username: '用戶名屬性', + setting_Claim_Email: '郵箱屬性', + setting_Claim_Display_Name: '顯示名屬性', + setting_Claim_Groups: '用戶組屬性', + setting_IdP_Initiated_Enabled: '允許 IdP 發起的 SSO', + setting_JIT_Provisioning_Enabled: '啟用即時(JIT)佈建', + setting_Login_Redirect_URL: '登入後跳轉地址', + setting_Saml_Login_Enable_doc: '啟用 SAML 2.0 單點登入', + setting_Saml_Sp_Entity_Id_doc: + 'SP 唯一標識,建議用 ACS 的基地址。範例:https://tapdata.example.com/api/sso/saml', + setting_Saml_Sp_Acs_Url_doc: + 'IdP 回發斷言的地址,固定為 https://<你的網域>/api/sso/saml/acs', + setting_Saml_Sp_Private_Key_doc: + '服務提供方私鑰(靜態加密存儲,只寫),與 SP 憑證透過「產生金鑰對」成對產生', + setting_Saml_Sp_Certificate_doc: + 'SP 憑證(公鑰,PEM),會被打包進匯出的 SP 中繼資料。請透過「產生金鑰對」產生', + setting_Saml_Idp_Entity_Id_doc: '身份提供方(IdP)實體 ID,由「匯入 IdP 中繼資料」自動填充', + setting_Saml_Idp_Sso_Url_doc: + '身份提供方 SSO(登入)地址。ADFS 範例:https://adfs.example.com/adfs/ls/', + setting_Saml_Idp_Slo_Url_doc: + '身份提供方單點登出(SLO)地址。ADFS 範例:https://adfs.example.com/adfs/ls/?wa=wsignout1.0', + setting_Saml_Idp_Signing_Certificate_doc: + '身份提供方簽名憑證,用於驗證斷言簽名,由「匯入 IdP 中繼資料」自動填充', + setting_Saml_Name_Id_Format_doc: + 'SAML NameID 格式;留空用預設。範例:urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress', + setting_Saml_Want_Assertions_Signed_doc: + '要求 IdP 對斷言簽名;TapData 會用 IdP 簽名憑證驗簽,簽名不過即拒登,防止偽造/竄改。ADFS 預設簽名,建議保持開啟', + setting_Saml_Sign_Authn_Request_doc: + '用 SP 私鑰對發給 IdP 的登入請求(AuthnRequest)簽名;僅當 IdP 要求 SP 簽名請求時開啟', + setting_Saml_Signature_Algorithm_doc: + 'XML 簽名演算法;留空用預設(rsa-sha256)。避免使用 SHA-1/MD5 等弱演算法', + setting_Saml_Clock_Skew_Seconds_doc: + '校驗斷言時間條件時允許的時鐘偏差(秒),預設 120;跨機注意 NTP 同步', + setting_Saml_Claim_Username_doc: + '映射為用戶名的 SAML 屬性名。ADFS 範例:http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name', + setting_Saml_Claim_Email_doc: + '映射為郵箱的 SAML 屬性名。ADFS 範例:http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress', + setting_Saml_Claim_Display_Name_doc: + '映射為顯示名的 SAML 屬性名。ADFS 範例:http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname', + setting_Saml_Claim_Groups_doc: + '映射為用戶組的 SAML 屬性名。ADFS 範例:http://schemas.xmlsoap.org/claims/Group', + setting_Saml_Idp_Initiated_Enabled_doc: + '允許 IdP 發起的 SSO(出於安全考慮預設關閉)', + setting_Saml_Jit_Provisioning_Enabled_doc: + '即時(JIT)佈建:首次 SSO 登入時自動創建用戶(預設關閉)', + setting_Saml_Login_Redirect_Url_doc: 'SSO 登入成功後的跳轉地址', + setting_saml_generate_keypair: '產生金鑰對', + setting_saml_export_sp_metadata: '匯出 SP 中繼資料', + setting_saml_import_idp_metadata: '匯入 IdP 中繼資料', + setting_saml_import_idp_metadata_label: '上傳 IdP 中繼資料 XML 檔案', + setting_saml_import_idp_metadata_placeholder: '或在此貼上 IdP 中繼資料 XML', + setting_saml_keypair_generated: + '金鑰對已產生,私鑰已加密存儲;點擊「儲存」以持久化憑證', + setting_saml_import_success: + 'IdP 中繼資料已解析,IdP 欄位已回填;點擊「儲存」以持久化', + setting_saml_import_empty: '請先上傳或貼上 IdP 中繼資料 XML', setting_Appearance: '外觀', setting_enableEnvTag: '環境標籤', setting_envTagContent: '自定義文字', @@ -839,6 +909,32 @@ export default { user_list_freeze_error: '凍結失敗', user_list_check_success: '通過校驗', user_list_check_error: '校驗失敗', + user_import_batch_import: '批量導入', + user_import_dialog_title: '批量導入用戶', + user_import_dialog_tip: '下載模板並填寫用戶列表,然後上傳進行校驗和導入。', + user_import_download_template: '下載模板', + user_import_template_download_failed: '下載模板失敗', + user_import_select_file: '請先選擇檔案', + user_import_mode: '用戶已存在時', + user_import_mode_skip: '跳過', + user_import_mode_update: '更新', + user_import_validate: '校驗', + user_import_confirm: '確認導入', + user_import_preview_title: '校驗結果', + user_import_count_total: '共 {count}', + user_import_count_create: '新建 {count}', + user_import_count_update: '更新 {count}', + user_import_count_skip: '跳過 {count}', + user_import_count_failed: '失敗 {count}', + user_import_col_row: '行號', + user_import_col_status: '狀態', + user_import_col_message: '說明', + user_import_status_CREATE: '新建', + user_import_status_UPDATE: '更新', + user_import_status_SKIP: '跳過', + user_import_status_FAILED: '失敗', + user_import_result_summary: + '導入完成:新建 {create},更新 {update},跳過 {skip},失敗 {failed}', user_status_notVerified: '未驗證', user_status_notActivated: '未激活', user_status_activated: '已激活', @@ -1214,6 +1310,9 @@ export default { app_customerService_otherDmandsText: '其他需求,請掃描下方企業微信二維碼。', app_signIn_slogan: '像自來水一樣方便地使用您的數據', app_signIn_signIn: '登錄', + app_signIn_samlLogin: '單點登錄 (SAML)', + app_signIn_ssoProcessing: '正在登錄...', + app_signIn_ssoFailed: '單點登錄失敗,請重試。', app_signIn_keepSignIn: '保持登錄狀態', app_signIn_email_placeholder: '請輸入郵箱', login_email_and_ad_placeholder: '請輸入郵箱/LDAP 用戶名', diff --git a/apps/daas/src/router/index.ts b/apps/daas/src/router/index.ts index 61e9189f7..e6677ff5d 100644 --- a/apps/daas/src/router/index.ts +++ b/apps/daas/src/router/index.ts @@ -64,6 +64,7 @@ router.beforeEach(async (to, from, next) => { } else if ( [ 'login', + 'ssoCallback', 'registry', 'passwordReset', 'verificationEmail', diff --git a/apps/daas/src/router/routes.ts b/apps/daas/src/router/routes.ts index 8be76d6b1..06a4ad726 100644 --- a/apps/daas/src/router/routes.ts +++ b/apps/daas/src/router/routes.ts @@ -74,6 +74,14 @@ export const routes = [ component: () => import('@/views/login/Login.vue'), title: 'tap.login', }, + { + path: '/sso-callback', + name: 'ssoCallback', + component: () => import('@/views/login/SsoCallback.vue'), + meta: { + title: 'tap.login', + }, + }, { path: '/verificationEmail', name: 'verificationEmail', diff --git a/apps/daas/src/utils/util.ts b/apps/daas/src/utils/util.ts index eb3540918..2c9ba536a 100644 --- a/apps/daas/src/utils/util.ts +++ b/apps/daas/src/utils/util.ts @@ -1,3 +1,4 @@ +import { getSamlLogoutUrl } from '@tap/api/src/core/sso' import { getUserInfoByToken } from '@tap/api/src/core/users' import Cookie from '@tap/shared/src/cookie' import dayjs from 'dayjs' @@ -148,13 +149,24 @@ export function ensurePermissions() { } export function signOut() { + // Capture SSO state before clearing cookies so a SAML session can trigger + // SP-initiated Single Logout at the IdP. + const isSamlSession = Cookie.get('auth_method') === 'saml' + const accessToken = Cookie.get('access_token') Cookie.remove('access_token') Cookie.remove('email') Cookie.remove('username') Cookie.remove('isAdmin') Cookie.remove('user_id') - sessionStorage.setItem('lastLocationHref', location.href) + Cookie.remove('auth_method') clearPermissions() + if (isSamlSession) { + // The backend terminates the local session, then redirects to the IdP SLO + // endpoint (or straight back to the login page when SLO is not configured). + window.location.href = getSamlLogoutUrl(accessToken) + return null + } + sessionStorage.setItem('lastLocationHref', location.href) location.href = `${location.href.split('#')[0]}#/login` return null } diff --git a/apps/daas/src/views/login/Login.vue b/apps/daas/src/views/login/Login.vue index 3fce45da7..57285b0a5 100644 --- a/apps/daas/src/views/login/Login.vue +++ b/apps/daas/src/views/login/Login.vue @@ -1,5 +1,9 @@ + + + + diff --git a/apps/daas/src/views/setting/Setting.vue b/apps/daas/src/views/setting/Setting.vue index 5fb8d30fd..01856070d 100644 --- a/apps/daas/src/views/setting/Setting.vue +++ b/apps/daas/src/views/setting/Setting.vue @@ -5,6 +5,11 @@ import { saveSettings, testEmail, } from '@tap/api/src/core/settings' +import { + exportSpMetadata, + generateSamlKeyPair, + importIdpMetadata, +} from '@tap/api/src/core/sso' import { testLdapLogin } from '@tap/api/src/core/users' import { showErrorMessage } from '@tap/business/src/components/error-message' import PageContainer from '@tap/business/src/components/PageContainer.vue' @@ -16,6 +21,7 @@ import { getCurrentLanguage } from '@tap/i18n/src/shared/util' import Cookie from '@tap/shared/src/cookie' import { setSettings } from '@tap/shared/src/settings' import Time from '@tap/shared/src/time' +import { downloadJson } from '@tap/shared/src/util' import { find, uniq } from 'lodash-es' import i18n from '@/i18n' @@ -92,6 +98,12 @@ export default { appearanceForm: {}, colorEnum: ['red', 'orange', 'yellow', 'blue', 'green', 'purple'], formItems: [], + samlKeyPairGenerating: false, + samlMetadataExporting: false, + samlImportDialog: false, + samlImportXml: '', + samlImportFileName: '', + samlImporting: false, } }, computed: { @@ -362,6 +374,103 @@ export default { }) }, + // 证书类字段统一用文件上传控件(LDAP SSL 证书 + SAML SP/IdP 证书) + isCertField(childItem) { + return ( + childItem.key_label === 'Ldap SSL Cert' || + childItem.key === 'saml.sp.certificate' || + childItem.key === 'saml.idp.signingCertificate' + ) + }, + + // 按 key 定位 SAML 分类里的某个配置行,用于回填 metadata / 证书 + findSamlItem(key) { + const category = find(this.formData.items, (item) => { + return item.category === 'SAML' + }) + return category ? find(category.items, (it) => it.key === key) : null + }, + + // 生成 SP 密钥对:私钥加密存于后端,仅返回证书,回填到 SP Certificate 行 + generateSamlKeyPair() { + this.samlKeyPairGenerating = true + generateSamlKeyPair() + .then((data) => { + const certItem = this.findSamlItem('saml.sp.certificate') + if (certItem) { + certItem.value = data?.spCertificate || '' + } + this.$message.success(this.$t('setting_saml_keypair_generated')) + }) + .catch((error) => { + showErrorMessage(error) + }) + .finally(() => { + this.samlKeyPairGenerating = false + }) + }, + + // 导出 SP 元数据 XML 供 IdP 导入 + exportSpMetadata() { + this.samlMetadataExporting = true + exportSpMetadata() + .then((xml) => { + downloadJson(xml, 'tapdata-sp-metadata.xml') + }) + .catch((error) => { + showErrorMessage(error) + }) + .finally(() => { + this.samlMetadataExporting = false + }) + }, + + openSamlImportDialog() { + this.samlImportXml = '' + this.samlImportFileName = '' + this.samlImportDialog = true + }, + + handleChangeSamlImportXml(value) { + this.samlImportXml = value || '' + }, + + handleChangeSamlImportName(name) { + this.samlImportFileName = name || '' + }, + + // 解析 IdP 元数据并回填 IdP 分组的四个字段(保存后才落库) + confirmImportIdpMetadata() { + if (!this.samlImportXml) { + this.$message.warning(this.$t('setting_saml_import_empty')) + return + } + this.samlImporting = true + importIdpMetadata(this.samlImportXml) + .then((data) => { + const mapping = { + 'saml.idp.entityId': data?.idpEntityId, + 'saml.idp.ssoUrl': data?.idpSsoUrl, + 'saml.idp.sloUrl': data?.idpSloUrl, + 'saml.idp.signingCertificate': data?.idpSigningCertificate, + } + Object.keys(mapping).forEach((key) => { + const item = this.findSamlItem(key) + if (item && mapping[key] != null) { + item.value = mapping[key] + } + }) + this.samlImportDialog = false + this.$message.success(this.$t('setting_saml_import_success')) + }) + .catch((error) => { + showErrorMessage(error) + }) + .finally(() => { + this.samlImporting = false + }) + }, + handleChangeCert(target, value) { target.value = value }, @@ -541,14 +650,14 @@ export default { - diff --git a/apps/daas/src/views/user/SsoUserImportDialog.vue b/apps/daas/src/views/user/SsoUserImportDialog.vue new file mode 100644 index 000000000..6b16f705e --- /dev/null +++ b/apps/daas/src/views/user/SsoUserImportDialog.vue @@ -0,0 +1,251 @@ + + + diff --git a/packages/api/src/core/sso.ts b/packages/api/src/core/sso.ts new file mode 100644 index 000000000..d26cd5e5c --- /dev/null +++ b/packages/api/src/core/sso.ts @@ -0,0 +1,163 @@ +import { requestClient } from '../request' + +const BASE_URL = '/api/sso/saml/config' +const SSO_BASE_URL = '/api/sso/saml' + +/** Same-origin hash route the IdP redirects back to after a successful login. */ +const SSO_CALLBACK_RELAY_STATE = '/#/sso-callback' +/** Same-origin hash route to land on after logout completes. */ +const SSO_LOGOUT_RELAY_STATE = '/#/login' + +/** Whether SAML SSO login is enabled (drives the login-page button visibility). */ +export function checkSamlLoginEnable() { + return requestClient.get(`${SSO_BASE_URL}/enabled`, { + skipErrorHandler: true, + }) +} + +/** + * Absolute URL that starts SP-initiated SAML login. The browser must navigate + * to it directly (window.location) so the IdP redirect chain runs in the tab; + * on success the backend redirects back to the SSO_CALLBACK_RELAY_STATE route + * with an access_token query param appended. + */ +export function getSamlLoginUrl() { + return `${SSO_BASE_URL}/login?relayState=${encodeURIComponent( + SSO_CALLBACK_RELAY_STATE, + )}` +} + +/** + * Absolute URL for SP-initiated Single Logout. The browser must navigate to it + * directly; the backend terminates the local session, then redirects to the IdP + * SLO endpoint (or straight to the login page when SLO is not configured). + */ +export function getSamlLogoutUrl(accessToken?: string) { + const relay = `relayState=${encodeURIComponent(SSO_LOGOUT_RELAY_STATE)}` + return accessToken + ? `${SSO_BASE_URL}/logout?access_token=${encodeURIComponent(accessToken)}&${relay}` + : `${SSO_BASE_URL}/logout?${relay}` +} + +/** IdP fields extracted from an imported IdP SAML metadata document. */ +export interface SamlIdpMetadata { + idpEntityId: string | null + idpSsoUrl: string | null + idpSloUrl: string | null + idpSigningCertificate: string | null +} + +/** Result of validating the SAML configuration before it is enabled. */ +export interface SamlValidationResult { + valid: boolean + errors: string[] + warnings: string[] +} + +/** + * Generate and store a new SP signing/decryption key pair. The private key is + * encrypted and stored server-side; only the certificate (PEM) is returned. + */ +export function generateSamlKeyPair() { + return requestClient.post<{ spCertificate: string }>( + `${BASE_URL}/generate-keypair`, + ) +} + +/** Export the TapData SP metadata XML for import into the IdP. */ +export function exportSpMetadata() { + return requestClient.get(`${BASE_URL}/export-sp-metadata`) +} + +/** Parse an IdP metadata XML document to prefill the IdP-side fields. */ +export function importIdpMetadata(metadataXml: string) { + return requestClient.post( + `${BASE_URL}/import-idp-metadata`, + { + metadataXml, + }, + ) +} + +/** Import behaviour for existing users when a row matches an existing account. */ +export type SsoUserImportMode = 'SKIP' | 'UPDATE' + +/** Per-row outcome of a batch user import (dry-run preview or confirmed apply). */ +export interface SsoImportRowResult { + /** 1-based data-row index in the uploaded file. */ + row: number + email: string + username: string + displayName: string + roleNames: string[] + status: 'CREATE' | 'UPDATE' | 'SKIP' | 'FAILED' + /** Human-readable reason, primarily for FAILED rows. */ + message: string +} + +/** Aggregate result of a batch user import. */ +export interface SsoImportPreviewResult { + /** True when this is a dry-run (no writes performed). */ + dryRun: boolean + total: number + createCount: number + updateCount: number + skipCount: number + failedCount: number + rows: SsoImportRowResult[] +} + +/** + * Download the SSO batch user import template (.xlsx). Returns the raw axios + * response so the caller can hand it to downloadBlob. + */ +export function downloadSsoUserImportTemplate() { + return requestClient.get(`${BASE_URL}/user-import/template`, { + responseType: 'blob', + responseReturn: 'raw', + skipErrorHandler: true, + }) +} + +/** + * Validate an SSO batch user import file (dry-run, no writes). Returns a + * per-row preview of what would happen. + */ +export function validateSsoUserImport( + file: File, + mode: SsoUserImportMode = 'SKIP', +) { + const formData = new FormData() + formData.append('file', file) + + return requestClient.post( + `${BASE_URL}/user-import/validate`, + formData, + { + params: { mode }, + headers: { + 'Content-Type': 'multipart/form-data', + }, + }, + ) +} + +/** Apply an SSO batch user import file. */ +export function confirmSsoUserImport( + file: File, + mode: SsoUserImportMode = 'SKIP', +) { + const formData = new FormData() + formData.append('file', file) + + return requestClient.post( + `${BASE_URL}/user-import/confirm`, + formData, + { + params: { mode }, + headers: { + 'Content-Type': 'multipart/form-data', + }, + }, + ) +} From 42938daef2d135fbebbc9e342d37b024069d25d8 Mon Sep 17 00:00:00 2001 From: mnianqi Date: Thu, 20 Aug 2026 10:38:36 +0800 Subject: [PATCH 2/5] feat: TAP-12033 add sp slo url --- apps/daas/src/i18n/langs/en.js | 9 +++++++++ apps/daas/src/i18n/langs/zh-CN.js | 6 ++++++ apps/daas/src/i18n/langs/zh-TW.js | 6 ++++++ apps/daas/src/views/login/Login.vue | 26 ++++++++++++++++++++++++-- 4 files changed, 45 insertions(+), 2 deletions(-) diff --git a/apps/daas/src/i18n/langs/en.js b/apps/daas/src/i18n/langs/en.js index ef2e02de2..2f7c5b72d 100644 --- a/apps/daas/src/i18n/langs/en.js +++ b/apps/daas/src/i18n/langs/en.js @@ -872,6 +872,7 @@ export default { setting_SAML_Login_Enable: 'Use SAML Login', setting_SP_Entity_ID: 'SP Entity ID', setting_SP_ACS_URL: 'SP ACS URL', + setting_SP_SLO_URL: 'SP SLO URL', setting_SP_Certificate: 'SP Certificate', setting_IdP_Entity_ID: 'IdP Entity ID', setting_IdP_SSO_URL: 'IdP SSO URL', @@ -895,6 +896,8 @@ export default { 'Service Provider unique identifier. Recommended to use the ACS base URL, e.g. https://tapdata.example.com/api/sso/saml', setting_Saml_Sp_Acs_Url_doc: 'Assertion Consumer Service URL that receives the SAML response. Fixed as https:///api/sso/saml/acs', + setting_Saml_Sp_Slo_Url_doc: + 'Single Logout (SLO) URL advertised in the SP metadata, used to receive the IdP LogoutRequest/LogoutResponse. Fixed as https:///api/sso/saml/slo', setting_Saml_Sp_Private_Key_doc: 'Service Provider private key (encrypted at rest, write-only). Generated together with the SP certificate via "Generate Key Pair".', setting_Saml_Sp_Certificate_doc: @@ -1400,6 +1403,12 @@ export default { app_signIn_samlLogin: 'Single Sign-On (SAML)', app_signIn_ssoProcessing: 'Signing you in...', app_signIn_ssoFailed: 'Single sign-on failed. Please try again.', + app_signIn_ssoUserDisabled: + 'Your account is disabled or frozen. Please contact an administrator.', + app_signIn_ssoUserPending: + 'Your account is pending approval. Please contact an administrator.', + app_signIn_ssoUserNotFound: + 'No matching TapData account was found for this SSO identity.', app_signIn_keepSignIn: 'Keep signed in', app_signIn_email_placeholder: 'Enter your email', login_email_and_ad_placeholder: 'Enter your email/LDAP username', diff --git a/apps/daas/src/i18n/langs/zh-CN.js b/apps/daas/src/i18n/langs/zh-CN.js index 6875a442d..462fc3b73 100644 --- a/apps/daas/src/i18n/langs/zh-CN.js +++ b/apps/daas/src/i18n/langs/zh-CN.js @@ -818,6 +818,7 @@ export default { setting_SAML_Login_Enable: '使用SAML登录', setting_SP_Entity_ID: 'SP Entity ID', setting_SP_ACS_URL: 'SP ACS 地址', + setting_SP_SLO_URL: 'SP SLO 地址', setting_SP_Certificate: 'SP 证书', setting_IdP_Entity_ID: 'IdP Entity ID', setting_IdP_SSO_URL: 'IdP SSO 地址', @@ -840,6 +841,8 @@ export default { 'SP 唯一标识,建议用 ACS 的基地址。示例:https://tapdata.example.com/api/sso/saml', setting_Saml_Sp_Acs_Url_doc: 'IdP 回发断言的地址,固定为 https://<你的域名>/api/sso/saml/acs', + setting_Saml_Sp_Slo_Url_doc: + 'SP 元数据中公布的单点登出(SLO)地址,用于接收 IdP 的登出请求/响应,固定为 https://<你的域名>/api/sso/saml/slo', setting_Saml_Sp_Private_Key_doc: '服务提供方私钥(静态加密存储,只写),与 SP 证书通过「生成密钥对」成对生成', setting_Saml_Sp_Certificate_doc: @@ -1321,6 +1324,9 @@ export default { app_signIn_samlLogin: '单点登录 (SAML)', app_signIn_ssoProcessing: '正在登录...', app_signIn_ssoFailed: '单点登录失败,请重试。', + app_signIn_ssoUserDisabled: '账号已被冻结或禁用,请联系管理员。', + app_signIn_ssoUserPending: '账号正在等待审批,请联系管理员。', + app_signIn_ssoUserNotFound: '未找到与此 SSO 身份匹配的 TapData 账号。', app_signIn_keepSignIn: '保持登录状态', app_signIn_email_placeholder: '请输入邮箱', login_email_and_ad_placeholder: '请输入邮箱/LDAP 用户名', diff --git a/apps/daas/src/i18n/langs/zh-TW.js b/apps/daas/src/i18n/langs/zh-TW.js index f624076df..61c6917c3 100644 --- a/apps/daas/src/i18n/langs/zh-TW.js +++ b/apps/daas/src/i18n/langs/zh-TW.js @@ -814,6 +814,7 @@ export default { setting_SAML_Login_Enable: '使用SAML登入', setting_SP_Entity_ID: 'SP Entity ID', setting_SP_ACS_URL: 'SP ACS 地址', + setting_SP_SLO_URL: 'SP SLO 地址', setting_SP_Certificate: 'SP 憑證', setting_IdP_Entity_ID: 'IdP Entity ID', setting_IdP_SSO_URL: 'IdP SSO 地址', @@ -836,6 +837,8 @@ export default { 'SP 唯一標識,建議用 ACS 的基地址。範例:https://tapdata.example.com/api/sso/saml', setting_Saml_Sp_Acs_Url_doc: 'IdP 回發斷言的地址,固定為 https://<你的網域>/api/sso/saml/acs', + setting_Saml_Sp_Slo_Url_doc: + 'SP 中繼資料中公布的單點登出(SLO)地址,用於接收 IdP 的登出請求/回應,固定為 https://<你的網域>/api/sso/saml/slo', setting_Saml_Sp_Private_Key_doc: '服務提供方私鑰(靜態加密存儲,只寫),與 SP 憑證透過「產生金鑰對」成對產生', setting_Saml_Sp_Certificate_doc: @@ -1313,6 +1316,9 @@ export default { app_signIn_samlLogin: '單點登錄 (SAML)', app_signIn_ssoProcessing: '正在登錄...', app_signIn_ssoFailed: '單點登錄失敗,請重試。', + app_signIn_ssoUserDisabled: '帳號已被凍結或停用,請聯絡管理員。', + app_signIn_ssoUserPending: '帳號正在等待審批,請聯絡管理員。', + app_signIn_ssoUserNotFound: '找不到與此 SSO 身分匹配的 TapData 帳號。', app_signIn_keepSignIn: '保持登錄狀態', app_signIn_email_placeholder: '請輸入郵箱', login_email_and_ad_placeholder: '請輸入郵箱/LDAP 用戶名', diff --git a/apps/daas/src/views/login/Login.vue b/apps/daas/src/views/login/Login.vue index 57285b0a5..3fc1640bb 100644 --- a/apps/daas/src/views/login/Login.vue +++ b/apps/daas/src/views/login/Login.vue @@ -41,9 +41,22 @@ export default { this.loadSamlEnable() if (this.$route.query) { this.form.email = this.$route.query.email + const ssoError = this.$route.query.sso_error + if (ssoError) { + this.errorMessage = this.getSsoErrorMessage(ssoError) + } } }, methods: { + getSsoErrorMessage(code) { + const messages = { + user_disabled: 'app_signIn_ssoUserDisabled', + user_pending: 'app_signIn_ssoUserPending', + user_not_found: 'app_signIn_ssoUserNotFound', + sso_failed: 'app_signIn_ssoFailed', + } + return this.$t(messages[code] || messages.sso_failed) + }, async loadAdEnable() { const data = await checkLdapLoginEnable() this.adEnable = data @@ -188,7 +201,7 @@ export default { @@ -301,6 +314,15 @@ export default { user-select: none; } } + + // Element Plus adds a default left margin to adjacent buttons. Since these + // actions are stacked and both span the panel width, remove that margin so + // their left and right edges stay aligned. + .login-action { + display: flex; + margin-left: 0 !important; + box-sizing: border-box; + } } } From 72613612a119500e892247deb69e75548c7f54e9 Mon Sep 17 00:00:00 2001 From: mnianqi Date: Thu, 20 Aug 2026 15:18:30 +0800 Subject: [PATCH 3/5] feat: TAP-12033 hidden batch import user button when saml is not enabled --- apps/daas/src/views/user/List.vue | 12 +++++++++++- packages/api/src/core/sso.ts | 1 - 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/apps/daas/src/views/user/List.vue b/apps/daas/src/views/user/List.vue index 9c0f7e9e7..502e03e6d 100644 --- a/apps/daas/src/views/user/List.vue +++ b/apps/daas/src/views/user/List.vue @@ -1,6 +1,7 @@