跳到主要内容

新增 MFA 验证

要求

适用环境

  • 私有部署

ONES 版本

v6.3.0+

依赖包版本

{
"dependencies": {
"@ones-op/fetch": "0.46.12+",
"@ones-open/node-host": "0.4.2+",
"@ones/cli-plugin": "1.29.0+",
"@ones/cli-plugin-template": "1.10.8+",
"@ones-op/sdk": "0.46.7+"
}
}

特别地,还需要在插件 backend 也安装 @ones-open/node-host

cd backend/
npm install @ones-open/node-host@0.4.2+

能力概述

本文档介绍如何通过功能扩展添加一种 MFA 验证方式(例如手机短信验证)。

功能扩展配置

config/plugin.yaml 中添加以下配置:

oauth:
type:
- admin
scope:
- read:account:user
extension:
- twoFactorAuthenticator:
provider: smsProvider
funcs:
- name: getTwoFactorAuthenticatorName
url: getTwoFactorAuthenticatorName
- name: hasBound
url: hasBound
- name: isCodeValid
url: isCodeValid
- name: bind
url: bind
slots:
- name: ones:global:authenticator:verify:new
entryUrl: modules/ones-global-authenticator-verify-new-pnME/index.html
- name: ones:global:authenticator:bind:new
entryUrl: modules/ones-global-authenticator-bind-new-NNPG/index.html
- name: ones:global:authenticator:verify:h5:new
entryUrl: modules/ones-global-authenticator-verify-h5-new-N7lg/index.html

注意:@ones/cli 工具暂时还不支持生成功能扩展的配置文件,需要手动填写。

后端实现

功能概览

后端需要实现以下函数:

  • getTwoFactorAuthenticatorName:获取验证方式名称
  • hasBound:检查用户是否已绑定验证设备(如手机号)
  • isCodeValid:检查验证码是否正确
  • bind:为用户绑定设备(如手机号)

实现步骤

  1. 创建文件 backend/src/phone-verification.ts
import { FetchAsAdmin } from '@ones-op/fetch'
import { Logger } from '@ones-op/node-logger'
import type { PluginRequest, PluginResponse } from '@ones-op/node-types'

// 存储用户和设备的绑定关系
let userDeviceBindings: { [key: string]: string } = {}

export async function getTwoFactorAuthenticatorName(body: any): Promise<PluginResponse> {
const languages = body?.languages
Logger.info('languages', languages)
return {
statusCode: 200,
body: {
code: 200,
body: {
name: '手机短信验证器',
},
},
}
}

export async function hasBound(body: any): Promise<PluginResponse> {
const authUserUUID = body?.auth_user_uuid
Logger.info('hasBound authUserUUID', authUserUUID)

if (userDeviceBindings[authUserUUID]) {
return {
statusCode: 200,
body: {
code: 200,
body: {
has_bound_device: true,
},
},
}
} else {
return {
statusCode: 200,
body: {
code: 200,
body: {
has_bound_device: false,
},
},
}
}
}

export async function isCodeValid(body: any): Promise<PluginResponse> {
const sessionID = body?.session_id
const authUserUUID = body?.auth_user_uuid
const code = body?.code
Logger.info(
'isCodeValid sessionID: ',
sessionID,
' authUserUUID: ',
authUserUUID,
' code: ',
code,
)

if (code === '123456') {
return {
statusCode: 200,
body: {
code: 200,
body: {
is_valid: true,
},
},
}
} else {
return {
statusCode: 200,
body: {
code: 200,
body: {
is_valid: false,
},
},
}
}
}

export async function bind(body: any): Promise<PluginResponse> {
const sessionID = body?.session_id
const authUserUUID = body?.auth_user_uuid
const identifier = body?.identifier
const code = body?.code
Logger.info(
'bind sessionID: ',
sessionID,
' authUserUUID: ',
authUserUUID,
' identifier: ',
identifier,
' code: ',
code,
)

if (code !== '123456') {
return {
statusCode: 200,
body: {
code: 400,
errcode: 'Plugin.CodeInvalid',
model: 'Plugin.Code',
reason: '验证码错误',
type: 'error',
},
}
}

userDeviceBindings[authUserUUID] = identifier
return {
statusCode: 200,
body: {
code: 200,
body: {},
},
}
}

// 用于导入用户和手机号码绑定关系时,通过组织和用户 uuid 换取 auth_user_uuid
export async function fetchOrganizationAuthUserUUIDs(
organizationID: string,
userUUIDs: string[],
): Promise<Map<string, string>> {
const url = `openapi/v2/account/organization/${organizationID}/auth_user_uuid`
const res = await FetchAsAdmin<{
result: string
data: {
auth_user_uuid: string
user_uuid: string
}[]
}>(url, {
method: 'POST',
params: {
organizationID: organizationID,
},
data: {
users: userUUIDs,
},
headers: {
'Content-Type': 'application/json',
},
})

const authUserUUIDMap = new Map<string, string>()
for (const user of res.data.data) {
authUserUUIDMap.set(user.user_uuid, user.auth_user_uuid)
}
return authUserUUIDMap
}
  1. backend/src/index.ts 中导出上述函数:
export * from './phone-verification'

函数使用场景和作用

getTwoFactorAuthenticatorName

用于返回新的验证方式的名称。

入参说明:

字段说明数据示例获取方式
languages语言数组["zh"]body?.languages

建议根据不同的语言返回对应的验证方式名称。

hasBound

入参说明:

字段说明数据示例
auth_user_uuid用户标识DAAprqQf

这个函数使用场景是:用户登录时,需要知道用户是否已经绑定过手机号,如果绑定过,会跳转到二次验证。开发者要做的事情是根据 auth_user_uuid 去查数据库,看这个用户是否绑定过手机号。

注意,auth_user_uuid 不是用户 uuid,下文会解释 auth_user_uuid 和用户 uuid 的区别以及怎么转换。

isCodeValid

入参说明:

字段说明数据示例
session_idmfa_session_idP1cx1xZmn8ojEd1znKRFRB
auth_user_uuid用户标识DAAprqQf
code验证码123456

用户已经绑定过手机号,登录时,会要求输入手机验证码验证,所以这个函数要做的事情是,检查验证码是否正确。

而 isCodeValid 要做的事情和整个验证流程密切相关,所以先说明登录二次验证的流程:

插件后端需要在插件前端请求发送验证码接口后,需要生成验证码,发给对应用户,并且把验证码存起来,以备下一步在 isCodeValid 对比校验。

综上,isCodeValid 要做的事情就是对比发送出去的验证码和用户自己输入的验证码是否一样,一样则验证通过,反之亦然。

bind

入参说明:

字段说明数据示例
session_idmfa_session_id
auth_user_uuid用户标识DAAprqQf
identifier设备标识,比如手机号12627860611
code验证码123456

如果系统开启了二次验证,而用户又还没有绑定过设备,就需要绑定设备,跟验证类似,在 bind 函数里需要对比发送出去的验证码和用户自己输入的验证码是否一样,一样的话就把用户提交的手机号和用户绑定。

fetchOrganizationAuthUserUUIDs

在导入绑定关系时调这个函数换取 auth_user_uuid。

插件后端需要调用的接口

这部分讲述在登录认证和登录绑定流程中需要调用的标品接口。

1. 登录后获取 auth_user_uuid

登录验证时:

POST https://{domain}/identity/api/auth_user_uuid
{
"mfa_session_uuid": "P1cx1xZmn8ojEd1znKRFRB"
}

返回:
{
"auth_user_uuid": "9xWqdCYS"
}

标品前端会把 mfa_session_uuid 传到插件前端,插件前端需要传到插件后端,由插件后端来调此接口。

登录绑定时:

GET https://{domain}/identity/api/org_users

返回:
{
"org_users": [
{
"auth_user_uuid": "JHxDWf7R",
"region_uuid": "default",
"org_uuid": "WQW1smav",
"org_user": {
"org_user_uuid": "HtC7pJmL",
"name": "blue",
"avatar": "",
"status": 1
},
"org": {
"region_uuid": "default",
"org_uuid": "WQW1smav",
"name": "Team_Rocket",
"logo": ""
},
"create_time": 1732046441659,
"is_org_owner": true
}
]
}

绑定时使用 auth_user_uuid。

插件调用代码示例:

try {
const userList = await OPFetch<{
org_users: {
auth_user_uuid: string
region_uuid: string
org_uuid: string
org_user: {
org_user_uuid: string
name: string
avatar: string
status: number
}
org: {
region_uuid: string
org_uuid: string
name: string
logo: string
}
create_time: number
is_org_owner: boolean
}[]
}>({
url: 'http://ones-identity-api-service/api/org_users',
method: 'GET',
headers: {
authorization: 'Bearer ' + sessionID,
},
})
for (const user of userList.data.org_users) {
Logger.info('user', user)
}
} catch (e) {
Logger.error('e', e)
}

2. 根据 org_uuid, org_user_uuid 换取 auth_user_uuid

用于批量导入用户和手机号绑定关系场景,上面已经提供了 fetchOrganizationAuthUserUUIDs 函数。

auth_user_uuid 说明

auth_user_uuid 和用户 uuid 的关系是:一个 auth_user_uuid 对应多个用户 uuid,因为一个人可以有多个组织(SaaS)。在组织内部使用用户 uuid,而插件的各个函数接收到的参数都是 auth_user_uuid。

可以使用 fetchOrganizationAuthUserUUIDs 函数通过组织 uuid 和用户 uuid 获取 auth_user_uuid。

前端实现

插槽说明

插槽名称插槽描述
ones:global:authenticator:verify:newMFA验证PC端
ones:global:authenticator:verify:h5:newMFA验证H5端
ones:global:authenticator:bind:newMFA绑定

注意:MFA 验证需要分别实现 PC 端和 H5 端两个插槽,它们获取到的参数完全一样。MFA 绑定只有 PC 端插槽,H5 不支持 MFA 绑定。

MFA 验证实现

  1. 创建 PC 端验证页面 web/src/modules/ones-global-authenticator-verify-new-pnME/index.tsx
import { ConfigProvider } from '@ones-design/core'
import { lifecycle, OPProvider } from '@ones-op/bridge'
import React from 'react'
import ReactDOM from 'react-dom'

import AuthenticatorVerify from './authenticator-verify'

import './index.css'

ReactDOM.render(
<ConfigProvider>
<OPProvider>
<AuthenticatorVerify />
</OPProvider>
</ConfigProvider>,
document.getElementById('ones-mf-root'),
)

lifecycle.onDestroy(() => {
ReactDOM.unmountComponentAtNode(document.getElementById('ones-mf-root'))
})
  1. 创建验证组件 authenticator-verify.tsx
import { Input, Form, Button } from '@ones-design/core'
import { useProps } from '@ones-op/sdk'
import React, { useCallback, type FC } from 'react'

const CODE_RULES = [{ required: true }]

const AuthenticatorVerify: FC = () => {
const { onLoginMFA, mfaSessionUUID } = useProps('ones:global:authenticator:verify:new')
const [form] = Form.useForm()

const onFinish = useCallback(
(formData) => {
onLoginMFA(formData.code).catch((err) => {
console.log('do your own error logic', err)
})
},
[onLoginMFA],
)

return (
<div>
<Form form={form} onFinish={onFinish}>
<Form.Item rules={CODE_RULES} name="code">
<Input placeholder="请输入验证码" crossOrigin />
</Form.Item>
<Form.Item>
<Button type="primary" block htmlType="submit">
登录
</Button>
</Form.Item>
</Form>
</div>
)
}

export default AuthenticatorVerify

MFA 绑定实现

  1. 创建绑定页面 web/src/modules/ones-global-authenticator-bind-new-NNPG/index.tsx
import { ConfigProvider } from '@ones-design/core'
import { lifecycle, OPProvider } from '@ones-op/bridge'
import React from 'react'
import ReactDOM from 'react-dom'

import AuthenticatorBind from './authenticator-bind'

import './index.css'

ReactDOM.render(
<ConfigProvider>
<OPProvider>
<AuthenticatorBind />
</OPProvider>
</ConfigProvider>,
document.getElementById('ones-mf-root'),
)

lifecycle.onDestroy(() => {
ReactDOM.unmountComponentAtNode(document.getElementById('ones-mf-root'))
})
  1. 创建绑定组件 authenticator-bind.tsx
import { Input, Form, Button } from '@ones-design/core'
import { useProps } from '@ones-op/sdk'
import React, { useCallback, type FC } from 'react'

const BASE_RULES = [{ required: true }]

const AuthenticatorBind: FC = () => {
const { onBindMFA } = useProps('ones:global:authenticator:bind:new')
const [form] = Form.useForm()

const onFinish = useCallback(
(formData) => {
onBindMFA(formData.identifier, formData.code).catch((err) => {
console.log('do your own error logic', err)
})
},
[onBindMFA],
)

return (
<div>
<Form form={form} onFinish={onFinish}>
<Form.Item rules={BASE_RULES} name="identifier">
<Input placeholder="请输入手机号" crossOrigin />
</Form.Item>
<Form.Item rules={BASE_RULES} name="code">
<Input placeholder="请输入验证码" crossOrigin />
</Form.Item>
<Form.Item>
<Button type="primary" block htmlType="submit">
绑定
</Button>
</Form.Item>
</Form>
</div>
)
}

export default AuthenticatorBind

错误处理

当插件需要返回业务错误时,应按以下格式返回:

return {
statusCode: 200,
body: {
code: 400,
errcode: 'Plugin.CodeIsInvalid',
model: 'Plugin.Code',
reason: '验证码错误',
type: 'error',
},
}

注意:

  • statusCode 必须是 200
  • body.code 必须是非 200
  • body.errcode 不能为空

系统配置

在服务器终端执行以下命令:

ones-ai-k8s.sh
vi config/private.yaml

按需添加或修改以下配置
需要停用系统默认二次验证方式:

totpIsDisabled: true # 是否禁用 TOTP 二次验证方式

有多种二次验证方式,想配置推荐使用的验证方式

recommendMfaMethod: cPw7h5Lc:smsProvider # 推荐的二次验证方式

修改后执行:

make setup-ones