feat(desktop): add face auth and enterprise auth (#5124)

* add face auth

* ok

* ok

* ok

* ok

* ok

* test

* ok

* ok

* ok

* ok
This commit is contained in:
limbo
2024-10-10 14:02:51 +08:00
committed by GitHub
parent 0704bd7f1f
commit a884a80fcb
30 changed files with 1928 additions and 714 deletions
+1
View File
@@ -42,4 +42,5 @@ yalc.lock
config.yaml
.env
data/config.local.yaml
#/prisma/region/generated/
+2 -2
View File
@@ -19,10 +19,10 @@ module.exports = {
endOfLine: 'lf',
overrides: [
{
files: 'config.yaml.local',
files: 'config.local.yaml',
options: {
parser: 'yaml'
}
}
]
};
}
+9
View File
@@ -4,6 +4,8 @@ cloud:
regionUID: "thisiaregionuid"
certSecretName: "wildcard-cert"
common:
enterpriseSupportingMaterials: ""
enterpriseRealNameAuthEnabled: false
realNameAuthEnabled: false
guideEnabled: false
apiEnabled: false
@@ -67,3 +69,10 @@ desktop:
# authURL: "{{ .oauth2AuthURL }}"
# tokenURL: "{{ .oauth2TokenURL }}"
# userInfoURL: "{{ .oauth2UserInfoURL }}"
realNameOSS:
accessKey: ""
accessKeySecret: ""
endpoint: ""
realNameBucket: ""
enterpriseRealNameBucket: ""
ssl: true
+3
View File
@@ -42,6 +42,7 @@
"decimal.js": "^10.4.3",
"eslint": "8.38.0",
"eslint-config-next": "13.3.0",
"formidable": "^3.5.1",
"framer-motion": "^10.16.4",
"i18next": "^23.11.5",
"immer": "^10.0.2",
@@ -65,6 +66,7 @@
"react-contexify": "^6.0.0",
"react-dom": "18.2.0",
"react-draggable": "^4.4.6",
"react-dropzone": "^14.2.3",
"react-hook-form": "^7.46.2",
"react-i18next": "^14.1.2",
"sass": "^1.68.0",
@@ -79,6 +81,7 @@
"devDependencies": {
"@testing-library/jest-dom": "^6.1.3",
"@testing-library/react": "^14.0.0",
"@types/formidable": "^3.4.5",
"@types/jest": "^29.5.10",
"@types/js-cookie": "^3.0.4",
"@types/js-yaml": "^4.0.6",
@@ -0,0 +1,19 @@
-- CreateTable
CREATE TABLE "EnterpriseRealNameInfo" (
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
"userUid" UUID NOT NULL,
"enterpriseName" STRING,
"enterpriseQualification" STRING,
"legalRepresentativePhone" STRING,
"isVerified" BOOL NOT NULL DEFAULT false,
"verificationStatus" STRING,
"createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMPTZ(3) NOT NULL,
"additionalInfo" JSONB,
"supportingMaterials" JSONB,
CONSTRAINT "EnterpriseRealNameInfo_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "EnterpriseRealNameInfo_userUid_key" ON "EnterpriseRealNameInfo"("userUid");
+28 -12
View File
@@ -244,25 +244,41 @@ model UserRealNameInfo {
@@map("UserRealNameInfo")
}
model EnterpriseRealNameInfo {
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
userUid String @unique @db.Uuid
enterpriseName String?
enterpriseQualification String?
legalRepresentativePhone String?
isVerified Boolean @default(false)
verificationStatus String?
createdAt DateTime @default(now()) @db.Timestamptz(3)
updatedAt DateTime @updatedAt @db.Timestamptz(3)
additionalInfo Json?
supportingMaterials Json?
@@map("EnterpriseRealNameInfo")
}
model RestrictedUser {
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
userUid String @unique @db.Uuid
restrictedLevel Int
createdAt DateTime @default(now()) @db.Timestamptz(3)
updatedAt DateTime @updatedAt @db.Timestamptz(3)
additionalInfo Json?
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
userUid String @unique @db.Uuid
restrictedLevel Int
createdAt DateTime @default(now()) @db.Timestamptz(3)
updatedAt DateTime @updatedAt @db.Timestamptz(3)
additionalInfo Json?
@@map("RestrictedUser")
}
model RealNameAuthProvider {
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
backend String
authType String
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
backend String
authType String
maxFailedTimes Int
config Json?
createdAt DateTime @default(now()) @db.Timestamptz(3)
updatedAt DateTime @updatedAt @db.Timestamptz(3)
config Json?
createdAt DateTime @default(now()) @db.Timestamptz(3)
updatedAt DateTime @updatedAt @db.Timestamptz(3)
@@map("RealNameAuthProvider")
}
+19 -12
View File
@@ -14,6 +14,7 @@
"and": "and",
"app_info": "App Info",
"app_launchpad": "App Launchpad",
"attachment": "appendix",
"application_desktop": "Application desktop",
"application_desktop_tips": "Installed application portal",
"avatar": "Avatar",
@@ -22,6 +23,7 @@
"bind_success": "Binding successful",
"bonus": "Bonus",
"bound": "Bound",
"business_license": "operating license",
"cancel": "Cancel",
"change": "Change",
"change_binding": "Change Binding",
@@ -31,6 +33,7 @@
"charge": "Charge",
"click_anywhere_to_continue": "Click on any blank space to continue",
"click_on_any_shadow_to_skip": "Click on any shadow to skip",
"click_to_upload_file": "Click to upload file",
"completed": "Finish",
"completed_the_deployment_of_an_nginx_for_the_first_time": "Completed the deployment of an nginx for the first time",
"confirm": "Confirm",
@@ -64,9 +67,15 @@
"emailchangesuccess": "Email modified successfully",
"enter": "Enter",
"enter_confirm": "Please enter {{value}} to confirm",
"enterprise_name": "Company name",
"enterprise_name_required": "Please enter the correct company name",
"enterprise_verification": "Enterprise real name",
"expected_to_use_next_month": "Expected to use next month",
"expected_used": "Expected used",
"face_recognition_failed": "Personal real name failed",
"face_recognition_success": "Personal real-name success",
"failed_to_generate_invitation_link": "Failed to generate invitation link",
"failed_to_get_qr_code": "Failed to obtain real name QR code",
"flow": "Traffic",
"force_delete_keywords": "All resources cannot be recovered after account deletion.",
"force_delete_tips": "There are still undeleted resources in your account. Once deleted, all resources will be unrecoverable. Please ensure you have backed up or transferred all important data.",
@@ -86,8 +95,6 @@
"hello_welcome": "Hello, welcome to",
"help_you_enable_high_availability_database": "Help you enable high availability database",
"home": "home",
"idCard": "ID CARD",
"idCard_invalid": "INVAILD ID CARD FORMAT",
"in_payment": "In Payment ...",
"in_time": "In Time",
"insufficient_balance": "Your account currently has outstanding payments",
@@ -130,7 +137,6 @@
"more_apps": "More Apps",
"name": "Name",
"name_of_team": "Name of Workspace",
"name_required": "The name format is wrong",
"new_email": "New email",
"new_phone": "New mobile number",
"newpassword": "New Password",
@@ -157,17 +163,16 @@
"payment_result": "Payment Result",
"payment_status": "Payment Status",
"payment_successful": "Payment Successful",
"personal_verification": "Personal real name",
"phone": "Phone",
"phone_invalid": "Invalid phone number format",
"phone_number_tips": "Phone Number",
"phonechangesuccess": "Mobile phone number modified successfully",
"placeholders_idCard": "Please enter your ID card number",
"placeholders_name": "Please enter your name",
"placeholders_phone": "Please enter your phone number",
"placeholders_verifycode": "please enter verification code",
"please_enter": "Please enter",
"please_enter_username": "Please enter your username",
"please_enter_your_enterprise_name": "Please enter your business name",
"please_fill_all_fields": "File cannot be empty",
"please_read_and_agree_to_the_agreement": "Please read and agree to the agreement",
"please_upload_the_supporting_materials": "Please sign and seal near the download, and upload a photo",
"privacy_policy": "Privacy Policy",
"private_team_id_of_user": "User's ID",
"purchase_history": "Purchase History",
@@ -180,11 +185,9 @@
"read_and_agree": "Please read and agree to the agreement below",
"realNameVerification": "Real Name Verification",
"realName_verification": "Real Name Verification",
"realname_auth_failed_tips": "The mobile number has been occupied by another account. Please unbind the mobile number or merge the accounts first.",
"realname_auth_now": "Click to verify your name",
"realname_auth_reminder": "Real-name authentication reminder",
"realname_auth_reminder_desc": "Domestic availability zones require real-name authentication, and use without real-name authentication will be restricted.",
"realname_auth_success": "Identity verification success",
"realname_auth_tips_a": "1. Please ensure that the name, ID number and mobile phone number filled in are consistent.",
"realname_auth_tips_b": "2. The number segments provided by some virtual operators may not pass verification. Please apply for a work order and pass manual verification.",
"realname_info": "RealName",
@@ -203,6 +206,8 @@
"remove": "Remove",
"remove_member_tips": "Determine that you want to remove the member?",
"rename": "Rename",
"retry_get_qr_code": "reacquire",
"scan_qr_code_for_face_recognition": "Use WeChat on your mobile phone to scan the QR code to complete identity verification",
"scan_with_wechat": "Scan with WeChat",
"sealos_copilot": "Sealos Copilot",
"sealos_document": "Sealos Document",
@@ -217,6 +222,7 @@
"status": "Status",
"storage": "Storage",
"submit_error": "Submit Error",
"supporting_materials": "Proof material",
"switching_disc": "Switching Disc",
"team": "Workspace",
"terminal": "Terminal",
@@ -228,6 +234,7 @@
"unbound": "Unbound",
"under_active_development": "Under active development 🚧",
"unread": "Unread",
"upload_success": "Upload successful",
"used_last_month": "Used last month",
"used_resources": "Used Resources",
"user_name": "User Name",
@@ -242,13 +249,13 @@
"task_launchpad_title": "Deploy App"
},
"verification_code_login": "with Phone",
"verifyCode_invalid": "Verification code format is incorrect",
"verify_code_tips": "6-digit Verification Code",
"verify_password": "Verify password",
"verifycode": "verification",
"view_discount_rules": "View recharge discount rules.",
"view_later": "Talk to You later",
"waiting": "Waiting",
"waiting_for_face_recognition": "Real name result query in progress...",
"warning": "Warning",
"wechat": "Wechat",
"work_order": "Work Order",
@@ -258,4 +265,4 @@
"you_can_view_fees_through_the_fee_center": "You can view fees through the fee center",
"you_have_not_purchased_the_license": "You have not purchased the License",
"yuan": "Yuan"
}
}
+20 -12
View File
@@ -13,6 +13,7 @@
"amount_forecast": "根据近一天消耗金额进行预测",
"and": "和",
"app_info": "应用信息",
"attachment": "附件",
"application_desktop": "应用桌面",
"application_desktop_tips": "已安装应用入口",
"avatar": "头像",
@@ -21,6 +22,7 @@
"bind_success": "绑定成功",
"bonus": "赠",
"bound": "已绑定",
"business_license": "营运执照",
"cancel": "取消",
"change": "变更",
"change_binding": "改绑",
@@ -30,6 +32,7 @@
"charge": "充值",
"click_anywhere_to_continue": "点击任意空白继续",
"click_on_any_shadow_to_skip": "点击任意阴影跳过",
"click_to_upload_file": "点击上传文件",
"completed": "完成",
"completed_the_deployment_of_an_nginx_for_the_first_time": "部署一个 nginx ,首次完成 将",
"confirm": "确认",
@@ -61,9 +64,15 @@
"emailchangesuccess": "电子邮箱修改成功",
"enter": "输入",
"enter_confirm": "请输入 {{value}} 确认",
"enterprise_name": "企业名称",
"enterprise_name_required": "请输入正确的企业名字",
"enterprise_verification": "企业实名",
"expected_to_use_next_month": "下月预计使用",
"expected_used": "预计还能使用",
"face_recognition_failed": "个人实名失败",
"face_recognition_success": "个人实名成功",
"failed_to_generate_invitation_link": "生成邀请链接失败",
"failed_to_get_qr_code": "获取实名二维码失败",
"flow": "流量",
"force_delete_keywords": "注销后所有资源无法恢复",
"force_delete_tips": "您的账号中仍有未删除的资源。一旦注销,所有资源将无法恢复。请确保已经备份或转移了所有重要数据。",
@@ -83,8 +92,6 @@
"hello_welcome": "您好, 欢迎来到",
"help_you_enable_high_availability_database": "帮您启用高可用数据库",
"home": "首页",
"idCard": "身份证",
"idCard_invalid": "身份证格式不对",
"in_payment": "支付中 ...",
"in_time": "加入时间",
"insufficient_balance": "您的账户目前存在未结清的款项",
@@ -126,7 +133,6 @@
"more_apps": "更多应用",
"name": "姓名",
"name_of_team": "工作空间名称",
"name_required": "姓名格式不对",
"new_email": "新电子邮箱",
"new_phone": "新手机号",
"newpassword": "新密码",
@@ -153,17 +159,17 @@
"payment_result": "支付结果",
"payment_status": "支付状态",
"payment_successful": "支付成功",
"personal_verification": "个人实名",
"phone": "手机号",
"phone_invalid": "电话号码格式不正确",
"phone_number_tips": "手机号",
"phonechangesuccess": "手机号修改成功",
"placeholders_idCard": "请输入您的身份证号码",
"placeholders_name": "请输入您的姓名",
"placeholders_phone": "请输入您的电话号码",
"placeholders_verifycode": "请输入验证码",
"please_enter": "请输入",
"please_enter_username": "请输入您的用户名",
"please_enter_your_enterprise_name": "请输入您的企业名称",
"please_fill_all_fields": "文件不能为空",
"please_read_and_agree_to_the_agreement": "请阅读并同意协议",
"please_upload_the_business_license": "请上传企业的运营资质照片",
"please_upload_the_supporting_materials": "请在下载的附近上签名并盖好公章,上传照片",
"privacy_policy": "隐私政策",
"private_team_id_of_user": "用户ID",
"purchase_history": "购买记录",
@@ -176,11 +182,9 @@
"read_and_agree": "请阅读并同意下方协议",
"realNameVerification": "实名认证",
"realName_verification": "实名认证",
"realname_auth_failed_tips": "手机号已被其他账号占用,请先解绑该手机号,或者完成账号合并操作。",
"realname_auth_now": "点击进行实名",
"realname_auth_reminder": "实名认证提醒",
"realname_auth_reminder_desc": "国内可用区需要实名认证,未实名认证将会被限制使用,",
"realname_auth_success": "实名认证成功",
"realname_auth_tips_a": "1、请确保所填写的姓名、身份证号码和手机号码信息一致。",
"realname_auth_tips_b": "2、部分虚拟运营商提供的号段可能无法验证通过,请申请工单通过人工协助认证。",
"realname_info": "实名信息",
@@ -199,6 +203,8 @@
"remove": "移除",
"remove_member_tips": "确认要移除该成员?",
"rename": "重命名",
"retry_get_qr_code": "重新获取",
"scan_qr_code_for_face_recognition": "使用手机微信扫描二维码,完成身份验证",
"scan_with_wechat": "微信扫码支付",
"sealos_copilot": "Sealos 小助理",
"sealos_newcomer_benefits": "Sealos 新手福利",
@@ -212,6 +218,7 @@
"status": "状态",
"storage": "存储",
"submit_error": "提交错误",
"supporting_materials": "证明材料",
"switching_disc": "切换圆盘",
"team": "工作空间",
"the_invited_user_must_be_others": "只能邀请其他人",
@@ -221,6 +228,7 @@
"unbound": "未绑定",
"under_active_development": "正在积极开发中 🚧",
"unread": "未读",
"upload_success": "上传成功",
"used_last_month": "上月已使用",
"used_resources": "已用资源",
"user_name": "用户名",
@@ -235,13 +243,13 @@
"task_appstore_title": "应用商店"
},
"verification_code_login": "手机号登录",
"verifyCode_invalid": "验证码格式不对",
"verify_code_tips": "6位验证码",
"verify_password": "确认密码",
"verifycode": "验证码",
"view_discount_rules": "查看优惠规则",
"view_later": "稍后再说",
"waiting": "等待中",
"waiting_for_face_recognition": "实名结果查询中...",
"warning": "警告",
"wechat": "微信",
"work_order": "工单",
@@ -251,4 +259,4 @@
"you_can_view_fees_through_the_fee_center": "您可通过费用中心查看费用",
"you_have_not_purchased_the_license": "您还没有购买 License",
"yuan": "元"
}
}
+28 -4
View File
@@ -58,6 +58,8 @@ export const _UserInfo = (request: AxiosInstance) => () =>
ApiResp<{
info: {
realName?: string;
enterpriseVerificationStatus?: string;
enterpriseRealName?: string;
userRestrictedLevel?: number;
uid: string;
createdAt: Date;
@@ -158,9 +160,29 @@ export const _checkRemainResource = (request: AxiosInstance) => () =>
export const _forceDeleteUser = (request: AxiosInstance) => (data: { code: string }) =>
request.post<never, ApiResp<DELETE_USER_STATUS>>('/api/auth/delete/force', data);
export const _realNameAuthRequest =
(request: AxiosInstance) => (data: { name: string; phone?: string; idCard: string }) =>
request.post<any, ApiResp<{ name: string }>>('/api/account/realNameAuth', data);
export const _faceAuthGenerateQRcodeUriRequest = (request: AxiosInstance) => () =>
request.get<any, ApiResp<{ url: string; bizToken: string }>>(
'/api/account/generateRealNameQRcodeUri'
);
export const _getFaceAuthStatusRequest = (request: AxiosInstance) => (data: { bizToken: string }) =>
request.post<any, ApiResp<{ status: string; realName: string }>>(
'/api/account/getFaceAuthStatus',
data
);
export const _enterpriseRealNameAuthRequest = (request: AxiosInstance) => (data: FormData) => {
return request.post<any, ApiResp<{ status: string }>>(
'/api/account/enterpriseRealNameAuth',
data,
{
headers: {
'Content-Type': 'multipart/form-data'
}
}
);
};
export const _getAmount = (request: AxiosInstance) => () =>
request<never, ApiResp<{ balance: number; deductionBalance: number }>>('/api/account/getAmount');
@@ -189,6 +211,8 @@ export const deleteUserRequest = _deleteUser(request);
export const checkRemainResource = _checkRemainResource(request);
export const forceDeleteUser = _forceDeleteUser(request);
export const realNameAuthRequest = _realNameAuthRequest(request);
export const enterpriseRealNameAuthRequest = _enterpriseRealNameAuthRequest(request);
export const faceAuthGenerateQRcodeUriRequest = _faceAuthGenerateQRcodeUriRequest(request);
export const getFaceAuthStatusRequest = _getFaceAuthStatusRequest(request);
export const getAmount = _getAmount(request);
@@ -222,8 +222,18 @@ export default function Index(props: Omit<IconButtonProps, 'aria-label'>) {
<ConfigItem
LeftElement={<Text>{t('common:realname_info')}</Text>}
RightElement={
infoData.data.enterpriseVerificationStatus === 'Success' ||
infoData.data.realName ? (
<Flex flex={1}>{infoData?.data.realName}</Flex>
<Flex flex={1}>
<Text
maxWidth="200px"
whiteSpace="nowrap"
overflow="hidden"
textOverflow="ellipsis"
>
{infoData?.data.enterpriseRealName || infoData?.data.realName}
</Text>
</Flex>
) : (
<Badge
cursor="pointer"
File diff suppressed because it is too large Load Diff
@@ -125,11 +125,13 @@ export default function Desktop(props: any) {
}, [openDesktopApp]);
useEffect(() => {
if (infoData.isSuccess && !infoData?.data?.realName && commonConfig?.realNameAuthEnabled) {
realNameAuthNotificationIdRef.current = realNameAuthNotification({
duration: null,
isClosable: true
});
if (infoData.isSuccess && commonConfig?.realNameAuthEnabled) {
if (!infoData?.data?.realName && infoData?.data?.enterpriseVerificationStatus !== 'Success') {
realNameAuthNotificationIdRef.current = realNameAuthNotification({
duration: null,
isClosable: true
});
}
}
return () => {
@@ -560,3 +560,87 @@ export function RightArrowIcon(props: IconProps) {
</Icon>
);
}
export function UploadIcon(props: IconProps) {
return (
<Icon
xmlns="http://www.w3.org/2000/svg"
width="21px" // Set width as per your original SVG
height="21px" // Set height as per your original SVG
viewBox="0 0 21 21"
fill="none"
{...props} // Spread props to allow customization
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M19.7377 12.6862C19.7377 14.9216 18.0184 16.7553 15.8301 16.9373V16.9521H5.73556V16.9512C3.42221 16.902 1.56201 15.0114 1.56201 12.6862C1.56201 10.6856 2.9392 9.00662 4.79727 8.54563C5.48431 5.95617 7.84424 4.04785 10.6499 4.04785C13.4555 4.04785 15.8155 5.95617 16.5025 8.54563C18.3606 9.00662 19.7377 10.6856 19.7377 12.6862ZM10.6499 15.1951C10.1897 15.1951 9.81656 14.822 9.81656 14.3617V11.0824L9.35839 11.5406C9.08506 11.8139 8.64192 11.8139 8.36859 11.5406C8.09527 11.2673 8.09527 10.8241 8.36859 10.5508L10.0317 8.88766C10.0505 8.86691 10.0703 8.84711 10.091 8.82834L10.1539 8.76549L10.155 8.76439C10.4283 8.49106 10.8715 8.49106 11.1448 8.76439L11.1459 8.76549L11.2087 8.82827C11.2295 8.84708 11.2493 8.86693 11.2681 8.88773L12.9301 10.5497C13.204 10.8236 13.204 11.2677 12.9301 11.5417C12.6562 11.8156 12.212 11.8156 11.9381 11.5417L11.4832 11.0868V14.3617C11.4832 14.822 11.1101 15.1951 10.6499 15.1951Z"
fill="#219BF4" // Set fill color as per your original SVG
/>
</Icon>
);
}
export function PictureIcon(props: IconProps) {
return (
<Icon
xmlns="http://www.w3.org/2000/svg"
width="17px" // Set width as per your original SVG
height="17px" // Set height as per your original SVG
viewBox="0 0 17 17"
fill="none"
{...props} // Spread props to allow customization
>
<path
d="M5.9408 7.31934C6.49309 7.31934 6.9408 6.87163 6.9408 6.31934C6.9408 5.76706 6.49309 5.31934 5.9408 5.31934C5.38852 5.31934 4.9408 5.76706 4.9408 6.31934C4.9408 6.87163 5.38852 7.31934 5.9408 7.31934Z"
fill="#00A9A6"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M1.98315 6.35172C1.98315 5.00759 1.98315 4.33553 2.24474 3.82214C2.47483 3.37055 2.84199 3.0034 3.29358 2.7733C3.80697 2.51172 4.47903 2.51172 5.82315 2.51172H11.4765C12.8206 2.51172 13.4927 2.51172 14.0061 2.7733C14.4577 3.0034 14.8248 3.37055 15.0549 3.82214C15.3165 4.33553 15.3165 5.00759 15.3165 6.35172V10.648C15.3165 11.9921 15.3165 12.6642 15.0549 13.1775C14.8248 13.6291 14.4577 13.9963 14.0061 14.2264C13.4927 14.488 12.8206 14.488 11.4765 14.488H5.82315C4.47903 14.488 3.80697 14.488 3.29358 14.2264C2.84199 13.9963 2.47483 13.6291 2.24474 13.1775C1.98315 12.6642 1.98315 11.9921 1.98315 10.648V6.35172ZM5.82315 3.84505H11.4765C12.1706 3.84505 12.6109 3.84609 12.9442 3.87332C13.262 3.89928 13.3633 3.94225 13.4007 3.96131C13.6015 4.06358 13.7646 4.22676 13.8669 4.42746C13.886 4.46487 13.9289 4.56623 13.9549 4.88398C13.9821 5.21729 13.9832 5.65765 13.9832 6.35172V10.648C13.9832 10.656 13.9832 10.664 13.9832 10.672L10.7493 7.43813C10.489 7.17778 10.0669 7.17778 9.80652 7.43813L4.14224 13.1024C3.98756 13.0789 3.9262 13.0523 3.8989 13.0384C3.69819 12.9361 3.53501 12.7729 3.43275 12.5722C3.41369 12.5348 3.37072 12.4335 3.34476 12.1157C3.31752 11.7824 3.31649 11.342 3.31649 10.648V6.35172C3.31649 5.65766 3.31752 5.2173 3.34476 4.88398C3.37072 4.56623 3.41369 4.46487 3.43275 4.42746C3.53501 4.22676 3.69819 4.06358 3.8989 3.96131C3.9363 3.94225 4.03766 3.89928 4.35541 3.87332C4.68873 3.84609 5.12909 3.84505 5.82315 3.84505ZM10.2779 8.85235L5.97563 13.1546H11.4765C12.1706 13.1546 12.6109 13.1536 12.9442 13.1264C13.262 13.1004 13.3633 13.0574 13.4007 13.0384C13.6015 12.9361 13.7646 12.7729 13.8669 12.5722C13.8751 12.5561 13.8878 12.5281 13.9016 12.476L10.2779 8.85235Z"
fill="#00A9A6"
/>
</Icon>
);
}
export function DeleteIcon(props: IconProps) {
return (
<Icon
xmlns="http://www.w3.org/2000/svg"
width="17px" // Set width as per your original SVG
height="17px" // Set height as per your original SVG
viewBox="0 0 17 17"
fill="none"
{...props} // Spread props to allow customization
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M8.13248 1.68018H9.16751C9.49052 1.68016 9.77219 1.68015 10.0048 1.69916C10.2512 1.71929 10.5007 1.76404 10.742 1.88697C11.099 2.06887 11.3892 2.35911 11.5711 2.71611C11.694 2.95738 11.7388 3.20684 11.7589 3.45328C11.7749 3.64934 11.7774 3.88026 11.7778 4.14141H14.1878C14.556 4.14141 14.8544 4.43989 14.8544 4.80808C14.8544 5.17627 14.556 5.47474 14.1878 5.47474H13.6238V11.7267C13.6238 12.2205 13.6238 12.6311 13.5965 12.9662C13.5679 13.3153 13.5064 13.6407 13.35 13.9478C13.1091 14.4205 12.7247 14.8049 12.2519 15.0458C11.9448 15.2023 11.6194 15.2638 11.2704 15.2923C10.9352 15.3197 10.5247 15.3197 10.0309 15.3197H7.26909C6.77534 15.3197 6.36475 15.3197 6.02963 15.2923C5.68059 15.2638 5.35515 15.2023 5.04807 15.0458C4.5753 14.8049 4.19092 14.4205 3.95003 13.9478C3.79356 13.6407 3.73205 13.3153 3.70354 12.9662C3.67616 12.6311 3.67616 12.2205 3.67617 11.7267L3.67617 5.47474H3.11222C2.74403 5.47474 2.44556 5.17627 2.44556 4.80808C2.44556 4.43989 2.74403 4.14141 3.11222 4.14141H5.52216C5.52255 3.88026 5.52506 3.64934 5.54108 3.45328C5.56121 3.20684 5.60596 2.95738 5.72889 2.71611C5.91079 2.35911 6.20104 2.06887 6.55803 1.88697C6.7993 1.76404 7.04876 1.71929 7.2952 1.69916C7.5278 1.68015 7.80947 1.68016 8.13248 1.68018ZM5.00951 5.47474V11.6995C5.00951 12.2274 5.01002 12.5833 5.03244 12.8576C5.05422 13.1242 5.09331 13.2547 5.13804 13.3425C5.2511 13.5643 5.4315 13.7447 5.65339 13.8578C5.74118 13.9025 5.87162 13.9416 6.13821 13.9634C6.41257 13.9858 6.76841 13.9863 7.29632 13.9863H10.0037C10.5316 13.9863 10.8874 13.9858 11.1618 13.9634C11.4284 13.9416 11.5588 13.9025 11.6466 13.8578C11.8685 13.7447 12.0489 13.5643 12.162 13.3425C12.2067 13.2547 12.2458 13.1242 12.2676 12.8576C12.29 12.5833 12.2905 12.2274 12.2905 11.6995V5.47474H5.00951ZM10.4444 4.14141H6.85555C6.85602 3.88297 6.85839 3.70371 6.86998 3.56185C6.88338 3.39787 6.9057 3.34341 6.9169 3.32143C6.97097 3.21532 7.05724 3.12904 7.16335 3.07498C7.18533 3.06378 7.23979 3.04146 7.40377 3.02806C7.57553 3.01403 7.80214 3.01351 8.15775 3.01351H9.14224C9.49785 3.01351 9.72446 3.01403 9.89622 3.02806C10.0602 3.04146 10.1147 3.06378 10.1366 3.07498C10.2428 3.12905 10.329 3.21532 10.3831 3.32143C10.3943 3.34341 10.4166 3.39787 10.43 3.56185C10.4416 3.70371 10.444 3.88297 10.4444 4.14141ZM7.41938 7.5256C7.78757 7.5256 8.08605 7.82408 8.08605 8.19227V11.2688C8.08605 11.637 7.78757 11.9355 7.41938 11.9355C7.05119 11.9355 6.75271 11.637 6.75271 11.2688V8.19227C6.75271 7.82408 7.05119 7.5256 7.41938 7.5256ZM9.88061 7.5256C10.2488 7.5256 10.5473 7.82408 10.5473 8.19227V11.2688C10.5473 11.637 10.2488 11.9355 9.88061 11.9355C9.51242 11.9355 9.21395 11.637 9.21395 11.2688V8.19227C9.21395 7.82408 9.51242 7.5256 9.88061 7.5256Z"
fill="#667085"
/>
</Icon>
);
}
export function AttachmentIcon(props: IconProps) {
return (
<Icon
xmlns="http://www.w3.org/2000/svg"
width="17px"
height="17px"
viewBox="0 0 17 17"
fill="none"
{...props}
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M15.9202 9.54936C15.9202 11.3969 14.4521 12.9016 12.6188 12.9603V12.9621H12.3508C11.9826 12.9621 11.6841 12.6636 11.6841 12.2954C11.6841 11.9273 11.9826 11.6288 12.3508 11.6288H12.5188C13.662 11.6227 14.5869 10.694 14.5869 9.54936C14.5869 8.57595 13.9168 7.75574 13.011 7.53099L12.2455 7.34109L12.0433 6.57883C11.6448 5.07708 10.2747 3.97201 8.64993 3.97201C7.02513 3.97201 5.65503 5.07708 5.25659 6.57883L5.05434 7.34109L4.28891 7.53099C3.38307 7.75574 2.71297 8.57595 2.71297 9.54936C2.71297 10.6978 3.64396 11.6288 4.79238 11.6288L4.79579 11.6288L4.98515 11.6288C5.35334 11.6288 5.65181 11.9273 5.65181 12.2954C5.65181 12.6636 5.35334 12.9621 4.98515 12.9621H4.79238H4.71848V12.9613C2.8678 12.922 1.37964 11.4095 1.37964 9.54936C1.37964 7.94886 2.48139 6.60569 3.96784 6.2369C4.51747 4.16533 6.40542 2.63867 8.64993 2.63867C10.8944 2.63867 12.7824 4.16533 13.332 6.2369C14.8185 6.60569 15.9202 7.94886 15.9202 9.54936ZM8.64992 7.80038C8.28173 7.80038 7.98325 8.09886 7.98325 8.46705V12.3437L7.61672 11.9771C7.39806 11.7585 7.04354 11.7585 6.82488 11.9771C6.60622 12.1958 6.60622 12.5503 6.82488 12.769L8.15536 14.0994C8.17039 14.116 8.18625 14.1319 8.20286 14.1469L8.25313 14.1972C8.47227 14.4163 8.82758 14.4163 9.04672 14.1972L9.09699 14.1469C9.1136 14.1319 9.12945 14.1161 9.14447 14.0994L10.4741 12.7698C10.6932 12.5507 10.6932 12.1954 10.4741 11.9762C10.2549 11.7571 9.89963 11.7571 9.68048 11.9762L9.31659 12.3401V8.46705C9.31659 8.09886 9.01811 7.80038 8.64992 7.80038Z"
fill="#0884DD"
/>
</Icon>
);
}
@@ -95,6 +95,9 @@ export default function usePassword({
userId: payload.userId,
userUid: payload.userUid,
realName: infoData.data?.info.realName || undefined,
enterpriseVerificationStatus:
infoData.data?.info.enterpriseVerificationStatus || undefined,
enterpriseRealName: infoData.data?.info.enterpriseRealName || undefined,
userRestrictedLevel: infoData.data?.info.userRestrictedLevel || undefined
},
kubeconfig: regionResult.data.kubeconfig
@@ -53,7 +53,10 @@ export default function useWechat() {
userUid: payload.userUid,
userId: payload.userId,
realName: infoData.data?.info.realName || undefined,
userRestrictedLevel: infoData.data?.info.userRestrictedLevel || undefined
userRestrictedLevel: infoData.data?.info.userRestrictedLevel || undefined,
enterpriseVerificationStatus:
infoData.data?.info.enterpriseVerificationStatus || undefined,
enterpriseRealName: infoData.data?.info.enterpriseRealName || undefined
},
// @ts-ignore
kubeconfig: result.data.kubeconfig
@@ -0,0 +1,245 @@
import { jsonRes } from '@/services/backend/response';
import { enableEnterpriseRealNameAuth } from '@/services/enable';
import type { NextApiRequest, NextApiResponse } from 'next';
import { verifyAccessToken } from '@/services/backend/auth';
import { globalPrisma } from '@/services/backend/db/init';
import { RealNameOSSConfigType } from '@/types';
import * as Minio from 'minio';
import formidable, { Fields, Files, File, Part } from 'formidable';
import path from 'path';
import Formidable from 'formidable/Formidable';
import fs from 'fs/promises';
export const config = {
api: {
bodyParser: false
}
};
const realNameOSS: RealNameOSSConfigType = global.AppConfig.realNameOSS;
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
const ALLOWED_FILE_TYPES = ['image/jpeg', 'image/png', 'application/pdf'];
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (!enableEnterpriseRealNameAuth) {
console.error('enterpriseRealNameAuth: enterprise real name authentication not enabled');
return jsonRes(res, { code: 503, message: 'Enterprise real name authentication not enabled' });
}
if (req.method !== 'POST') {
console.error('enterpriseRealNameAuth: Method not allowed');
return jsonRes(res, { code: 405, message: 'Method not allowed' });
}
const payload = await verifyAccessToken(req.headers);
if (!payload) return jsonRes(res, { code: 401, message: 'Token is invalid' });
if (!realNameOSS) {
return jsonRes(res, {
code: 500,
message: 'Real name authentication oss configuration not found'
});
}
try {
const userUid = payload.userUid;
const form = formidable({
multiples: false,
keepExtensions: true,
maxFileSize: MAX_FILE_SIZE,
filter: function (part: Part): boolean {
return part.mimetype !== null && ALLOWED_FILE_TYPES.includes(part.mimetype);
},
filename: function (name: string, ext: string, part: Part, form: Formidable): string {
const sanitizedName = sanitizeFilename(part.originalFilename || 'unnamed');
return sanitizedName;
}
});
const formData = await parseFormData(req, form);
const { fields, files } = formData;
const enterpriseName = fields.enterpriseName?.[0];
if ((enterpriseName && enterpriseName.length < 1) || enterpriseName.length > 20) {
return jsonRes(res, {
code: 400,
message: 'Enterprise name must be between 1 and 20 characters'
});
}
if (!files.enterpriseQualification?.[0] || !files.supportingMaterials?.[0]) {
return jsonRes(res, {
code: 400,
message: 'Enterprise qualification and supporting materials are required'
});
}
if (
files &&
files.enterpriseQualification?.[0] &&
files.enterpriseQualification?.[0].size > MAX_FILE_SIZE
) {
return jsonRes(res, {
code: 400,
message: 'Enterprise qualification file size exceeds the maximum limit'
});
}
if (
files &&
files.supportingMaterials?.[0] &&
files.supportingMaterials?.[0].size > MAX_FILE_SIZE
) {
return jsonRes(res, {
code: 400,
message: 'Supporting materials file size exceeds the maximum limit'
});
}
// Check if EnterpriseRealNameInfo exists
const existingInfo = await globalPrisma.enterpriseRealNameInfo.findUnique({
where: { userUid }
});
if (!existingInfo) {
// Create new EnterpriseRealNameInfo
const ossPaths = await uploadFiles(userUid, files);
await createEnterpriseRealNameInfo(userUid, enterpriseName, ossPaths);
return jsonRes(res, {
code: 200,
message: 'Enterprise real name authentication submitted successfully',
data: { status: 'Pending' }
});
}
// Handle existing EnterpriseRealNameInfo cases
switch (existingInfo.verificationStatus) {
case 'Pending':
return jsonRes(res, { code: 400, message: 'Authentication is under review' });
case 'Success':
return jsonRes(res, { code: 400, message: 'Cannot authenticate multiple times' });
case 'Failed':
// Re-upload files and update EnterpriseRealNameInfo
const newOssPaths = await uploadFiles(userUid, files);
await updateEnterpriseRealNameInfo(existingInfo.id, enterpriseName, newOssPaths);
return jsonRes(res, {
code: 200,
data: { status: 'Pending' },
message: 'Enterprise real name authentication resubmitted successfully'
});
default:
return jsonRes(res, { code: 500, message: 'Invalid verification status' });
}
} catch (error) {
console.error('enterpriseRealNameAuth: Internal error', error);
return jsonRes(res, { code: 500, message: 'The server has encountered an error' });
}
}
// Helper functions
async function parseFormData(req: NextApiRequest, form: Formidable): Promise<any> {
return new Promise((resolve, reject) => {
form.parse(req, (err: Error, fields: Fields, files: Files) => {
if (err) {
reject(err);
} else {
resolve({ fields, files });
}
});
});
}
function sanitizeFilename(filename: string): string {
// Remove any path components
const basename = path.basename(filename);
// Define a blacklist of malicious characters
const blacklist = /[<>:"/\\|?*\x00-\x1F]/g;
// Replace blacklisted characters with underscores
return basename.replace(blacklist, '_');
}
async function uploadFiles(userUid: string, files: Files): Promise<string[]> {
const minioConfig: Minio.ClientOptions = {
endPoint: realNameOSS.endpoint,
accessKey: realNameOSS.accessKey,
secretKey: realNameOSS.accessKeySecret,
useSSL: realNameOSS.ssl
};
const minioClient = new Minio.Client(minioConfig);
const filesToUpload = [
{ file: files.enterpriseQualification?.[0], name: 'enterpriseQualification' },
{ file: files.supportingMaterials?.[0], name: 'supportingMaterials' }
].filter((item) => item.file !== null);
const uploadPromises = filesToUpload.map((item) =>
uploadFile(minioClient, userUid, item.file!, item.name)
);
return await Promise.all(uploadPromises);
}
async function uploadFile(
minioClient: Minio.Client,
userUid: string,
file: File,
fileType: string
): Promise<string> {
const timestamp = Date.now();
const fileName = `${timestamp}_${fileType}_${file.newFilename}`;
const filePath = `/${userUid}/${fileName}`;
try {
await minioClient.fPutObject(realNameOSS.enterpriseRealNameBucket, filePath, file.filepath, {
'Content-Type': file.mimetype || 'application/octet-stream'
});
// Check if the file exists before attempting to delete it
try {
await fs.access(file.filepath);
// If no error is thrown, the file exists, so we can delete it
await fs.unlink(file.filepath);
console.debug(`File ${file.filepath} has been deleted.`);
} catch (accessError) {
// If an error is thrown, the file doesn't exist
console.debug(`File ${file.filepath} does not exist or is not accessible.`);
}
} catch (error) {
console.error('EnterpriseRealNameAuth uploadFile: Error uploading file', error);
throw error;
}
return filePath;
}
async function createEnterpriseRealNameInfo(
userUid: string,
enterpriseName: string,
ossPaths: string[]
) {
await globalPrisma.enterpriseRealNameInfo.create({
data: {
userUid,
enterpriseName: enterpriseName,
supportingMaterials: { ossPaths: ossPaths }, // Remaining paths for supportingMaterials
verificationStatus: 'Pending'
}
});
}
async function updateEnterpriseRealNameInfo(
id: string,
enterpriseName: string,
ossPaths: string[]
) {
await globalPrisma.enterpriseRealNameInfo.update({
where: { id },
data: {
enterpriseName: enterpriseName,
supportingMaterials: { ossPaths: ossPaths },
verificationStatus: 'Pending'
}
});
}
@@ -0,0 +1,308 @@
import { verifyAccessToken } from '@/services/backend/auth';
import { jsonRes } from '@/services/backend/response';
import { enableRealNameAuth } from '@/services/enable';
import * as tcsdk from 'tencentcloud-sdk-nodejs';
import { NextApiRequest, NextApiResponse } from 'next';
import { globalPrisma } from '@/services/backend/db/init';
import { GetDetectInfoEnhancedResponse } from 'tencentcloud-sdk-nodejs/tencentcloud/services/faceid/v20180301/faceid_models';
import { RealNameOSSConfigType } from '@/types';
import { Client, ClientOptions } from 'minio';
type TencentCloudFaceAuthConfig = {
secretId: string;
secretKey: string;
ruleId: string;
};
type JsonValue = string | number | boolean | object | null;
type RealNameAuthProvider = {
id: string;
backend: string;
authType: string;
maxFailedTimes: number;
config: JsonValue;
createdAt: Date;
updatedAt: Date;
};
type AdditionalInfo =
| {
faceRecognition?: {
callback?: {
bizToken?: string;
url?: string | null;
isUsed?: boolean;
createdAt?: number;
};
};
userMaterials?: string[];
}
| any;
const realNameOSS: RealNameOSSConfigType = global.AppConfig.realNameOSS;
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (!enableRealNameAuth) {
console.error('faceidRealNameAuth: Real name authentication not enabled');
return jsonRes(res, { code: 503, message: 'Real name authentication not enabled' });
}
const bizToken = req.query?.BizToken as string;
const extraQuery = req.query?.Extra as string;
const regionToken = extraQuery?.split('regionToken=')[1];
if (!regionToken) {
return jsonRes(res, { code: 400, message: 'Token is required' });
}
req.headers['authorization'] = regionToken;
const payload = await verifyAccessToken(req.headers);
if (!payload) return jsonRes(res, { code: 401, message: 'Token is invaild' });
if (!realNameOSS) {
return jsonRes(res, {
code: 500,
message: 'Real name authentication oss configuration not found'
});
}
try {
const realNameAuthProvider: RealNameAuthProvider | null =
await globalPrisma.realNameAuthProvider.findFirst({
where: {
backend: 'TENCENTCLOUD',
authType: 'tcloudFaceAuth'
}
});
if (!realNameAuthProvider) {
throw new Error('faceidRealNameAuth: Real name authentication provider not found');
}
const config: TencentCloudFaceAuthConfig =
realNameAuthProvider.config as TencentCloudFaceAuthConfig;
if (!config) {
throw new Error('faceidRealNameAuth: Real name authentication configuration not found');
}
const userRealNameFaceAuthInfo = await getUserRealNameInfo(bizToken, config);
const isFaceRecognitionSuccess = userRealNameFaceAuthInfo.Text?.ErrCode === 0;
const userUid = payload.userUid;
const timestamp = Date.now();
// Fetch existing user real name info
const userRealNameInfo = await globalPrisma.userRealNameInfo.findUnique({
where: { userUid }
});
if (!userRealNameInfo || !userRealNameInfo.additionalInfo) {
return jsonRes(res, { code: 400, message: 'User real name info not found' });
}
let additionalInfo: AdditionalInfo = userRealNameInfo.additionalInfo;
additionalInfo.faceRecognition.callback.isUsed = true;
// Initialize or reset userMaterials array
additionalInfo.userMaterials = [];
const minioConfig: ClientOptions = {
endPoint: realNameOSS.endpoint,
accessKey: realNameOSS.accessKey,
secretKey: realNameOSS.accessKeySecret,
useSSL: realNameOSS.ssl
};
const minioClient = new Client(minioConfig);
if (userRealNameFaceAuthInfo.BestFrame?.BestFrame) {
const imageBuffer = Buffer.from(userRealNameFaceAuthInfo.BestFrame.BestFrame, 'base64');
const imagePath = `${userUid}/${timestamp}_bestframe.jpg`;
await uploadFile(
minioClient,
realNameOSS.realNameBucket,
imagePath,
imageBuffer,
'image/jpeg'
);
additionalInfo.userMaterials.push(imagePath);
}
if (userRealNameFaceAuthInfo.VideoData?.LivenessVideo) {
const videoBuffer = Buffer.from(userRealNameFaceAuthInfo.VideoData.LivenessVideo, 'base64');
const videoPath = `${userUid}/${timestamp}_video.mp4`;
await uploadFile(
minioClient,
realNameOSS.realNameBucket,
videoPath,
videoBuffer,
'video/mp4'
);
additionalInfo.userMaterials.push(videoPath);
}
if (isFaceRecognitionSuccess) {
await globalPrisma.userRealNameInfo.update({
where: { userUid },
data: {
realName: userRealNameFaceAuthInfo.Text?.Name,
idCard: userRealNameFaceAuthInfo.Text?.IdCard,
isVerified: true,
additionalInfo
}
});
res.setHeader('Content-Type', 'text/html');
return res.send(`
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Real Name Authentication</title>
<style>
body, html {
height: 100%;
margin: 0;
display: flex;
justify-content: center;
align-items: center;
}
h1 {
text-align: center;
}
</style>
</head>
<body>
<h1>Real Name Authentication Successful</h1>
</body>
</html>
`);
} else {
await globalPrisma.userRealNameInfo.update({
where: { userUid },
data: {
isVerified: false,
idVerifyFailedTimes: { increment: 1 },
additionalInfo
}
});
res.setHeader('Content-Type', 'text/html');
return res.send(`
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Real Name Authentication</title>
<style>
body, html {
height: 100%;
margin: 0;
display: flex;
justify-content: center;
align-items: center;
}
h1 {
text-align: center;
color: #ff0000; /* Red color for error message */
}
</style>
</head>
<body>
<h1>Real Name Authentication Failed</h1>
</body>
</html>
`);
}
} catch (error) {
console.error('faceidRealNameAuth: Internal error');
console.error(error);
res.setHeader('Content-Type', 'text/html');
return res.status(500).send(`
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Server Error</title>
<style>
body, html {
height: 100%;
margin: 0;
display: flex;
justify-content: center;
align-items: center;
font-family: Arial, sans-serif;
}
.error-container {
text-align: center;
padding: 20px;
border: 1px solid #ff0000;
border-radius: 5px;
}
h1 {
color: #ff0000;
margin-bottom: 10px;
}
p {
color: #666;
}
</style>
</head>
<body>
<div class="error-container">
<h1>Server Error</h1>
<p>The server has encountered an error. Please try again later.</p>
</div>
</body>
</html>
`);
}
}
async function getUserRealNameInfo(
bizToken: string,
config: TencentCloudFaceAuthConfig
): Promise<GetDetectInfoEnhancedResponse> {
const FaceClient = tcsdk.faceid.v20180301.Client;
const client = new FaceClient({
credential: {
secretId: config.secretId,
secretKey: config.secretKey
},
region: '',
profile: {
signMethod: 'HmacSHA256',
httpProfile: {
endpoint: 'faceid.tencentcloudapi.com',
reqMethod: 'POST',
reqTimeout: 30 // Request timeout, default 60s
}
}
});
const params = {
BizToken: bizToken,
InfoType: '0',
RuleId: config.ruleId,
BestFramesCount: 0
};
const data = await client.GetDetectInfoEnhanced(params);
return data;
}
async function uploadFile(
minioClient: Client,
bucket: string,
path: string,
buffer: Buffer,
contentType: string
): Promise<void> {
await minioClient.putObject(bucket, path, buffer, buffer.length, { 'Content-Type': contentType });
}
@@ -0,0 +1,210 @@
import { jsonRes } from '@/services/backend/response';
import { enableRealNameAuth } from '@/services/enable';
import type { NextApiRequest, NextApiResponse } from 'next';
import * as tcsdk from 'tencentcloud-sdk-nodejs';
import { verifyAccessToken } from '@/services/backend/auth';
import { globalPrisma } from '@/services/backend/db/init';
type TencentCloudFaceAuthConfig = {
secretId: string;
secretKey: string;
ruleId: string;
};
type JsonValue = string | number | boolean | object | null;
type RealNameAuthProvider = {
id: string;
backend: string;
authType: string;
maxFailedTimes: number;
config: JsonValue;
createdAt: Date;
updatedAt: Date;
};
type AdditionalInfo =
| {
faceRecognition?: {
callback?: {
bizToken?: string;
url?: string | null;
isUsed?: boolean;
createdAt?: number;
};
};
userMaterials?: string[];
}
| any;
type QRCodeUrlResult = {
url: string;
bizToken: string;
};
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (!enableRealNameAuth) {
console.error('faceidRealNameAuth: Real name authentication not enabled');
return jsonRes(res, { code: 503, message: 'Real name authentication not enabled' });
}
if (req.method !== 'GET') {
console.error('faceidRealNameAuth: Method not allowed');
return jsonRes(res, { code: 405, message: 'Method not allowed' });
}
const payload = await verifyAccessToken(req.headers);
if (!payload) return jsonRes(res, { code: 401, message: 'Token is invaild' });
try {
const realNameAuthProvider: RealNameAuthProvider | null =
await globalPrisma.realNameAuthProvider.findFirst({
where: {
backend: 'TENCENTCLOUD',
authType: 'tcloudFaceAuth'
}
});
if (!realNameAuthProvider) {
throw new Error('faceidRealNameAuth: Real name authentication provider not found');
}
const config: TencentCloudFaceAuthConfig =
realNameAuthProvider.config as TencentCloudFaceAuthConfig;
if (!config) {
throw new Error('faceidRealNameAuth: Real name authentication configuration not found');
}
const realNameInfo = await globalPrisma.userRealNameInfo.findUnique({
where: {
userUid: payload.userUid
}
});
if (realNameInfo && realNameInfo.isVerified) {
console.info(`faceidRealNameAuth: User ${payload.userUid} has already been verified`);
return jsonRes(res, {
code: 409,
message: 'Identity verification has been completed, cannot be repeated.'
});
}
if (realNameInfo && realNameInfo.idVerifyFailedTimes >= realNameAuthProvider.maxFailedTimes) {
console.info(
`faceidRealNameAuth: User ${payload.userUid} has reached the maximum number of failed attempts`
);
return jsonRes(res, {
code: 429,
message: 'You have exceeded the maximum number of attempts. Please submit a ticket'
});
}
let urlResult: QRCodeUrlResult | null = null;
let additionalInfo: AdditionalInfo = realNameInfo?.additionalInfo || {};
const currentTime = new Date().getTime();
const urlCreatedAt = additionalInfo.faceRecognition?.callback?.createdAt || 0;
const urlExpirationTime = 7200 * 1000;
/* If the user has not been authenticated, or the authentication link has expired,
or the authentication link has already been used,
the authentication link needs to be regenerated.
*/
const shouldGenerateNewUrl =
!realNameInfo ||
!additionalInfo.faceRecognition?.callback?.url ||
additionalInfo.faceRecognition?.callback?.isUsed ||
currentTime - urlCreatedAt > urlExpirationTime;
if (shouldGenerateNewUrl) {
const redirectUrl = `https://${global.AppConfig?.cloud.domain}/api/account/faceIdRealNameAuthCallback`;
const regionToken = req.headers['authorization'] as string;
urlResult = await generateRealNameQRcodeUri(
redirectUrl,
regionToken,
config as TencentCloudFaceAuthConfig
);
additionalInfo = {
...additionalInfo,
faceRecognition: {
...additionalInfo.faceRecognition,
callback: {
bizToken: urlResult.bizToken,
url: urlResult.url,
isUsed: false,
createdAt: currentTime
}
}
};
await globalPrisma.userRealNameInfo.upsert({
where: { userUid: payload.userUid },
update: { additionalInfo },
create: {
userUid: payload.userUid,
isVerified: false,
idVerifyFailedTimes: 0,
additionalInfo
}
});
} else {
urlResult = {
url: additionalInfo.faceRecognition?.callback?.url || null,
bizToken: additionalInfo.faceRecognition?.callback?.bizToken || null
};
}
return jsonRes(res, {
code: 200,
message: 'success generate real name auth url',
data: { url: urlResult.url, bizToken: urlResult.bizToken }
});
} catch (error) {
console.error('faceidRealNameAuth: Internal error');
console.error(error);
return jsonRes(res, { code: 500, data: 'The server has encountered an error' });
}
}
async function generateRealNameQRcodeUri(
redirectUrl: string,
regionToken: string,
config: TencentCloudFaceAuthConfig
): Promise<QRCodeUrlResult> {
const FaceClient = tcsdk.faceid.v20180301.Client;
const client = new FaceClient({
credential: {
secretId: config.secretId,
secretKey: config.secretKey
},
region: '',
profile: {
signMethod: 'HmacSHA256',
httpProfile: {
endpoint: 'faceid.tencentcloudapi.com',
reqMethod: 'POST',
reqTimeout: 30 // Request timeout, default 60s
}
}
});
const params = {
RuleId: config.ruleId,
RedirectUrl: redirectUrl,
Extra: `regionToken=${regionToken}`
};
const data = await client.DetectAuth(params);
if (!data.Url || !data.BizToken) {
throw new Error('Failed to generate QR code URL: Missing Url or BizToken');
}
return {
url: data.Url,
bizToken: data.BizToken
};
}
@@ -0,0 +1,144 @@
import { jsonRes } from '@/services/backend/response';
import { enableRealNameAuth } from '@/services/enable';
import type { NextApiRequest, NextApiResponse } from 'next';
import * as tcsdk from 'tencentcloud-sdk-nodejs';
import { verifyAccessToken } from '@/services/backend/auth';
import { globalPrisma } from '@/services/backend/db/init';
import { z } from 'zod';
import { GetDetectInfoEnhancedResponse } from 'tencentcloud-sdk-nodejs/tencentcloud/services/faceid/v20180301/faceid_models';
type TencentCloudFaceAuthConfig = {
secretId: string;
secretKey: string;
ruleId: string;
};
type JsonValue = string | number | boolean | object | null;
type RealNameAuthProvider = {
id: string;
backend: string;
authType: string;
maxFailedTimes: number;
config: JsonValue;
createdAt: Date;
updatedAt: Date;
};
enum FaceAuthStatus {
SUCCESS = 'Success',
FAIL = 'Failed',
PENDING = 'Pending'
}
type FaceAuthResult = {
status: FaceAuthStatus;
realName?: string;
};
const bodySchema = z.object({
bizToken: z.string().length(36, { message: 'bizToken must be exactly 36 characters long' })
});
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (!enableRealNameAuth) {
console.error('faceidRealNameAuth: Real name authentication not enabled');
return jsonRes(res, { code: 503, message: 'Real name authentication not enabled' });
}
if (req.method !== 'POST') {
console.error('realNameAuth: Method not allowed');
return jsonRes(res, { code: 405, message: 'Method not allowed' });
}
const payload = await verifyAccessToken(req.headers);
if (!payload) return jsonRes(res, { code: 401, message: 'Token is invaild' });
const faceAuthResult: FaceAuthResult = {
status: FaceAuthStatus.PENDING
};
try {
const { bizToken } = bodySchema.parse(req.body);
const realNameAuthProvider: RealNameAuthProvider | null =
await globalPrisma.realNameAuthProvider.findFirst({
where: {
backend: 'TENCENTCLOUD',
authType: 'tcloudFaceAuth'
}
});
if (!realNameAuthProvider) {
throw new Error('faceidRealNameAuth: Real name authentication provider not found');
}
const config: TencentCloudFaceAuthConfig =
realNameAuthProvider.config as TencentCloudFaceAuthConfig;
if (!config) {
throw new Error('faceidRealNameAuth: Real name authentication configuration not found');
}
const faceAuthInfo = await getUserRealNameInfo(bizToken, config);
const realNameInfo = await globalPrisma.userRealNameInfo.findUnique({
where: {
userUid: payload.userUid
}
});
if (faceAuthInfo.Text?.ErrCode !== null && faceAuthInfo.Text?.ErrCode === 0) {
if (realNameInfo && realNameInfo.realName && realNameInfo.isVerified) {
faceAuthResult.status = FaceAuthStatus.SUCCESS;
faceAuthResult.realName = realNameInfo.realName;
}
}
if (faceAuthInfo.Text?.ErrCode !== null && faceAuthInfo.Text?.ErrCode !== 0) {
faceAuthResult.status = FaceAuthStatus.FAIL;
}
return jsonRes(res, {
code: 200,
message: 'success get face auth result',
data: { status: faceAuthResult.status, realName: faceAuthResult.realName }
});
} catch (error) {
console.error('faceidRealNameAuth: Internal error');
console.error(error);
return jsonRes(res, { code: 500, data: 'The server has encountered an error' });
}
}
async function getUserRealNameInfo(
bizToken: string,
config: TencentCloudFaceAuthConfig
): Promise<GetDetectInfoEnhancedResponse> {
const FaceClient = tcsdk.faceid.v20180301.Client;
const client = new FaceClient({
credential: {
secretId: config.secretId,
secretKey: config.secretKey
},
region: '',
profile: {
signMethod: 'HmacSHA256',
httpProfile: {
endpoint: 'faceid.tencentcloudapi.com',
reqMethod: 'POST',
reqTimeout: 30 // Request timeout, default 60s
}
}
});
const params = {
BizToken: bizToken,
InfoType: '0',
RuleId: config.ruleId,
BestFramesCount: 0
};
const data = await client.GetDetectInfoEnhanced(params);
return data;
}
@@ -1,219 +0,0 @@
import { jsonRes } from '@/services/backend/response';
import { enableRealNameAuth } from '@/services/enable';
import { z } from 'zod';
import type { NextApiRequest, NextApiResponse } from 'next';
import * as tcsdk from 'tencentcloud-sdk-nodejs';
import { verifyAccessToken } from '@/services/backend/auth';
import { identityCodeValid } from '@/utils/tools';
import { globalPrisma } from '@/services/backend/db/init';
type TencentCloudPhone3efConfig = {
secretId: string;
secretKey: string;
};
// type OtherBackendConfig = { ... };
// backend_authType
type ConfigMap = {
TENCENTCLOUD_tcloudphone3ef: TencentCloudPhone3efConfig;
// 'OTHER_BACKEND_authType': OtherBackendConfig;
};
type RealNameAuthProvider = {
id: string;
backend: string;
authType: string;
maxFailedTimes: number;
config: ConfigType;
createdAt: Date;
updatedAt: Date;
};
type ConfigType = {
[K in keyof ConfigMap]: RealNameAuthProvider extends { backend: infer B; authType: infer A }
? `${B extends string ? B : never}_${A extends string ? A : never}` extends K
? ConfigMap[K]
: never
: never;
}[keyof ConfigMap];
const bodySchema = z.object({
name: z
.string()
.min(1, { message: 'Name must not be empty' })
.max(20, { message: 'Name must not exceed 20 characters' }),
idCard: z.string().refine(identityCodeValid, { message: 'Invalid ID card number' })
});
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (!enableRealNameAuth) {
console.error('realNameAuth: Real name authentication not enabled');
return jsonRes(res, { code: 503, message: 'Real name authentication not enabled' });
}
if (req.method !== 'POST') {
console.error('realNameAuth: Method not allowed');
return jsonRes(res, { code: 405, message: 'Method not allowed' });
}
const payload = await verifyAccessToken(req.headers);
if (!payload) return jsonRes(res, { code: 401, message: 'Token is invaild' });
try {
const { name, idCard } = bodySchema.parse(req.body);
const oauthProvider = await globalPrisma.oauthProvider.findFirst({
where: {
userUid: payload.userUid,
providerType: 'PHONE'
}
});
if (!oauthProvider) {
console.error('realNameAuth: User has not bound phone number');
return jsonRes(res, { code: 400, message: 'Mobile number not bound' });
}
const phone = oauthProvider.providerId;
const realNameAuthProvider = (await globalPrisma.realNameAuthProvider.findFirst({
where: {
backend: 'TENCENTCLOUD',
authType: 'tcloudphone3ef'
}
})) as RealNameAuthProvider | null;
const config = realNameAuthProvider?.config;
if (!config) {
throw new Error('realNameAuth: Real name authentication configuration not found');
}
const realNameInfo = await globalPrisma.userRealNameInfo.findUnique({
where: {
userUid: payload.userUid
}
});
if (realNameInfo && realNameInfo.isVerified) {
console.info(`realNameAuth: User ${payload.userUid} has already been verified`);
return jsonRes(res, {
code: 409,
message: 'Identity verification has been completed, cannot be repeated.'
});
}
if (realNameInfo && realNameInfo.idVerifyFailedTimes >= realNameAuthProvider.maxFailedTimes) {
console.info(
`realNameAuth: User ${payload.userUid} has reached the maximum number of failed attempts`
);
return jsonRes(res, {
code: 429,
message: 'You have exceeded the maximum number of attempts. Please submit a ticket'
});
}
const { code, data } = await tcloudphone3efVerifyService(phone, name, idCard, config);
/* '-4' and '-5' are the results of chargeable interfaces,
and the number of failures is recorded for subsequent limitation.
*/
if (code === '-4' || code === '-5') {
await globalPrisma.userRealNameInfo.upsert({
where: { userUid: payload.userUid },
update: {
realName: name,
idCard: idCard,
phone: phone,
idVerifyFailedTimes: {
increment: 1
},
updatedAt: new Date()
},
create: {
userUid: payload.userUid,
realName: name,
idCard: idCard,
phone: phone,
idVerifyFailedTimes: 1,
isVerified: false,
createdAt: new Date(),
updatedAt: new Date()
}
});
}
if (code !== 0) {
console.info(
`realNameAuth: Real name authentication failed,useruid ${payload.userUid} code:${code} data:${data}`
);
return jsonRes(res, {
code: 400,
message:
'Identity verification failed. Please ensure that the name, ID number, and mobile number are consistent'
});
}
await globalPrisma.userRealNameInfo.upsert({
where: { userUid: payload.userUid },
update: {
realName: name,
idCard: idCard,
phone: phone,
isVerified: true,
updatedAt: new Date()
},
create: {
userUid: payload.userUid,
realName: name,
idCard: idCard,
phone: phone,
idVerifyFailedTimes: 0,
isVerified: true,
createdAt: new Date(),
updatedAt: new Date()
}
});
return jsonRes(res, { code: 200, message: 'Identity verification success', data: { name } });
} catch (error) {
console.error('realNameAuth: Internal error');
console.error(error);
return jsonRes(res, { code: 500, data: 'The server has encountered an error' });
}
}
async function tcloudphone3efVerifyService(
phone: string,
name: string,
idCard: string,
config: TencentCloudPhone3efConfig
) {
const FaceClient = tcsdk.faceid.v20180301.Client;
const client = new FaceClient({
credential: {
secretId: config.secretId,
secretKey: config.secretKey
},
profile: {
signMethod: 'HmacSHA256',
httpProfile: {
reqMethod: 'POST',
reqTimeout: 30 // Request timeout, default 60s
}
}
});
const res = await client.PhoneVerification({
Phone: phone,
IdCard: idCard,
Name: name
});
if (res?.Result !== '0') {
return { code: res?.Result, data: res?.Description };
}
return { code: 0, data: res?.Description };
}
+41 -28
View File
@@ -19,36 +19,42 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
code: 401,
message: 'invalid token'
});
const [regionData, globalData, realNameInfo, restrictedUser] = await Promise.all([
prisma.userCr.findUnique({
where: {
uid: regionUser.userCrUid
}
}),
globalPrisma.user.findUnique({
where: {
uid: regionUser.userUid
},
include: {
oauthProvider: {
select: {
providerType: true,
providerId: true
const [regionData, globalData, realNameInfo, enterpriseRealNameInfo, restrictedUser] =
await Promise.all([
prisma.userCr.findUnique({
where: {
uid: regionUser.userCrUid
}
}),
globalPrisma.user.findUnique({
where: {
uid: regionUser.userUid
},
include: {
oauthProvider: {
select: {
providerType: true,
providerId: true
}
}
}
}
}),
globalPrisma.userRealNameInfo.findUnique({
where: {
userUid: regionUser.userUid
}
}),
globalPrisma.restrictedUser.findUnique({
where: {
userUid: regionUser.userUid
}
})
]);
}),
globalPrisma.userRealNameInfo.findUnique({
where: {
userUid: regionUser.userUid
}
}),
globalPrisma.enterpriseRealNameInfo.findUnique({
where: {
userUid: regionUser.userUid
}
}),
globalPrisma.restrictedUser.findUnique({
where: {
userUid: regionUser.userUid
}
})
]);
if (!regionData || !globalData)
return jsonRes(res, {
@@ -66,6 +72,8 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
id: string;
name: string;
realName?: string;
enterpriseVerificationStatus?: string;
enterpriseRealName?: string;
userRestrictedLevel?: number;
} = {
...globalData,
@@ -98,6 +106,11 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
info.realName = realNameInfo.realName || undefined;
}
if (enterpriseRealNameInfo) {
info.enterpriseVerificationStatus = enterpriseRealNameInfo.verificationStatus || undefined;
info.enterpriseRealName = enterpriseRealNameInfo.enterpriseName || undefined;
}
if (restrictedUser) {
info.userRestrictedLevel = restrictedUser.restrictedLevel;
}
@@ -66,7 +66,7 @@ export async function getAuthClientConfig(): Promise<AuthClientConfigType> {
try {
if (process.env.NODE_ENV === 'development' || !global.AppConfig) {
const filename =
process.env.NODE_ENV === 'development' ? 'data/config.yaml.local' : '/app/data/config.yaml';
process.env.NODE_ENV === 'development' ? 'data/config.local.yaml' : '/app/data/config.yaml';
global.AppConfig = yaml.load(readFileSync(filename, 'utf-8')) as AppConfigType;
}
return genResAuthClientConfig(global.AppConfig.desktop.auth);
@@ -25,7 +25,7 @@ export async function getCloudConfig(): Promise<CloudConfigType> {
if (!global.AppConfig) {
const filename =
process.env.NODE_ENV === 'development'
? process.env.CONFIG_PATH || 'data/config.yaml.local'
? process.env.CONFIG_PATH || 'data/config.local.yaml'
: '/app/data/config.yaml';
global.AppConfig = yaml.load(readFileSync(filename, 'utf-8')) as AppConfigType;
}
@@ -18,17 +18,19 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
}
function genResCommonClientConfig(common: CommonConfigType): CommonClientConfigType {
return {
enterpriseRealNameAuthEnabled: !!common.enterpriseRealNameAuthEnabled,
realNameAuthEnabled: !!common.realNameAuthEnabled,
guideEnabled: !!common.guideEnabled,
rechargeEnabled: !!common.rechargeEnabled,
cfSiteKey: common.cfSiteKey || ''
cfSiteKey: common.cfSiteKey || '',
enterpriseSupportingMaterials: common.enterpriseSupportingMaterials || ''
};
}
export async function getCommonClientConfig(): Promise<CommonClientConfigType> {
try {
if (!global.AppConfig) {
const filename =
process.env.NODE_ENV === 'development' ? 'data/config.yaml.local' : '/app/data/config.yaml';
process.env.NODE_ENV === 'development' ? 'data/config.local.yaml' : '/app/data/config.yaml';
global.AppConfig = yaml.load(readFileSync(filename, 'utf-8')) as AppConfigType;
}
return genResCommonClientConfig(global.AppConfig.common);
@@ -16,7 +16,7 @@ export async function getLayoutConfig(): Promise<LayoutConfigType> {
try {
if (!global.AppConfig) {
const filename =
process.env.NODE_ENV === 'development' ? 'data/config.yaml.local' : '/app/data/config.yaml';
process.env.NODE_ENV === 'development' ? 'data/config.local.yaml' : '/app/data/config.yaml';
global.AppConfig = yaml.load(readFileSync(filename, 'utf-8')) as AppConfigType;
}
return global.AppConfig.desktop.layout || DefaultLayoutConfig;
+2
View File
@@ -1,5 +1,7 @@
// for service
export const enableRealNameAuth = () => global.AppConfig.common.realNameAuthEnabled || false;
export const enableEnterpriseRealNameAuth = () =>
global.AppConfig.common.enterpriseRealNameAuthEnabled || false;
export const enablePassword = () => global.AppConfig.desktop.auth.idp.password?.enabled || false;
export const enableGithub = () => global.AppConfig.desktop.auth.idp.github?.enabled || false;
export const enablePhoneSms = () => global.AppConfig.desktop.auth.idp.sms?.ali?.enabled || false;
+2
View File
@@ -8,6 +8,8 @@ export type OAuthToken = {
export type UserInfo = {
readonly userRestrictedLevel?: number;
readonly realName?: string;
readonly enterpriseVerificationStatus?: string;
readonly enterpriseRealName?: string;
readonly k8s_username: string;
readonly name: string;
readonly avatar: string;
+16
View File
@@ -8,6 +8,8 @@ export type CloudConfigType = {
};
export type CommonConfigType = {
enterpriseRealNameAuthEnabled: boolean;
enterpriseSupportingMaterials: string;
realNameAuthEnabled: boolean;
guideEnabled: boolean;
apiEnabled: boolean;
@@ -176,12 +178,24 @@ export type DesktopConfigType<T = AuthConfigType> = {
};
};
export type RealNameOSSConfigType = {
accessKey: string;
accessKeySecret: string;
endpoint: string;
ssl?: boolean;
port?: number;
realNameBucket: string;
enterpriseRealNameBucket: string;
};
export type AppConfigType = {
cloud: CloudConfigType;
common: CommonConfigType;
database: DatabaseConfigType;
desktop: DesktopConfigType;
realNameOSS: RealNameOSSConfigType;
};
export type AppClientConfigType = {
cloud: CloudConfigType;
common: CommonClientConfigType;
@@ -189,6 +203,8 @@ export type AppClientConfigType = {
};
export const DefaultCommonClientConfig: CommonClientConfigType = {
enterpriseRealNameAuthEnabled: false,
enterpriseSupportingMaterials: '',
realNameAuthEnabled: false,
guideEnabled: false,
rechargeEnabled: false,
@@ -22,6 +22,8 @@ export const sessionConfig = async ({
user: {
userRestrictedLevel: infoData.data?.info.userRestrictedLevel || undefined,
realName: infoData.data?.info.realName || undefined,
enterpriseVerificationStatus: infoData.data?.info.enterpriseVerificationStatus || undefined,
enterpriseRealName: infoData.data?.info.enterpriseRealName || undefined,
k8s_username: payload.userCrName,
name: infoData.data?.info.nickname || '',
avatar: infoData.data?.info.avatarUri || '',
+9
View File
@@ -135,6 +135,9 @@ importers:
eslint-config-next:
specifier: 13.3.0
version: 13.3.0(eslint@8.38.0)(typescript@5.2.2)
formidable:
specifier: ^3.5.1
version: 3.5.1
framer-motion:
specifier: ^10.16.4
version: 10.16.5(react-dom@18.2.0)(react@18.2.0)
@@ -204,6 +207,9 @@ importers:
react-draggable:
specifier: ^4.4.6
version: 4.4.6(react-dom@18.2.0)(react@18.2.0)
react-dropzone:
specifier: ^14.2.3
version: 14.2.3(react@18.2.0)
react-hook-form:
specifier: ^7.46.2
version: 7.48.2(react@18.2.0)
@@ -241,6 +247,9 @@ importers:
'@testing-library/react':
specifier: ^14.0.0
version: 14.1.2(react-dom@18.2.0)(react@18.2.0)
'@types/formidable':
specifier: ^3.4.5
version: 3.4.5
'@types/jest':
specifier: ^29.5.10
version: 29.5.10