Add MFA Verification
Requirements
Applicable Environments
- Private Deployment
ONES Version
v6.3.0+
Dependency Package Versions
{
"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+"
}
}
Especially, you also need to install @ones-open/node-host in the plugin backend
cd backend/
npm install @ones-open/node-host@0.4.2+
Capability Overview
This document describes how to add an MFA verification method (such as SMS verification) through functional extensions.
Extension Configuration
Add the following configuration to 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
Note: The @ones/cli tool currently does not support generating configuration files for functional extensions, so you need to fill it in manually.
Backend Implementation
Function Overview
The backend needs to implement the following functions:
- getTwoFactorAuthenticatorName: Get the name of the verification method
- hasBound: Check if the user has bound a verification device (such as a phone number)
- isCodeValid: Check if the verification code is correct
- bind: Bind a device (such as a phone number) for the user
Implementation Steps
- Create the file
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'
// Store user-device binding relationships
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: 'SMS Authenticator',
},
},
}
}
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: 'Invalid verification code',
type: 'error',
},
}
}
userDeviceBindings[authUserUUID] = identifier
return {
statusCode: 200,
body: {
code: 200,
body: {},
},
}
}
// Used for importing user-phone binding relationships, converting organization and user uuid to 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
}
- Export the above functions in
backend/src/index.ts
:
export * from './phone-verification'
Function Usage Scenarios and Purposes
getTwoFactorAuthenticatorName
Used to return the name of the new verification method.
Parameter description:
Field | Description | Data Example | Retrieval Method |
---|---|---|---|
languages | Language array | ["en"] | body?.languages |
It is recommended to return the corresponding verification method name according to different languages.
hasBound
Parameter description:
Field | Description | Data Example |
---|---|---|
auth_user_uuid | User ID | DAAprqQf |
The usage scenario of this function is: When a user logs in, we need to know whether the user has bound a phone number. If they have, they will be redirected to two-factor verification. Developers need to check the database with the auth_user_uuid to see if this user has bound a phone number.
Note that auth_user_uuid is not the user uuid. The difference between auth_user_uuid and user uuid and how to convert them will be explained below.
isCodeValid
Parameter description:
Field | Description | Data Example |
---|---|---|
session_id | mfa_session_id | P1cx1xZmn8ojEd1znKRFRB |
auth_user_uuid | User ID | DAAprqQf |
code | Verification code | 123456 |
When a user who has bound a phone number logs in, they will be asked to enter a phone verification code. This function checks whether the verification code is correct.
The function isCodeValid is closely related to the entire verification process. Here's how the login two-factor authentication process works:
After the plugin frontend requests to send a verification code, the plugin backend needs to generate a verification code, send it to the corresponding user, and store it for comparison in isCodeValid in the next step.
In summary, isCodeValid compares the verification code sent out with the code entered by the user. If they match, verification passes; otherwise, it fails.
bind
Parameter description:
Field | Description | Data Example |
---|---|---|
session_id | mfa_session_id | |
auth_user_uuid | User ID | DAAprqQf |
identifier | Device identifier, e.g., phone number | 12627860611 |
code | Verification code | 123456 |
If the system has enabled two-factor authentication and the user has not bound a device yet, they need to bind a device. Similar to verification, the bind function needs to compare the verification code sent out with the code entered by the user. If they match, the user's submitted phone number is bound to the user.
fetchOrganizationAuthUserUUIDs
This function is called when importing binding relationships to exchange for auth_user_uuid.
APIs That the Plugin Backend Needs to Call
This section describes the standard product APIs that need to be called in the login authentication and login binding processes.
1. Get auth_user_uuid After Login
During login verification:
POST https://{domain}/identity/api/auth_user_uuid
{
"mfa_session_uuid": "P1cx1xZmn8ojEd1znKRFRB"
}
Response:
{
"auth_user_uuid": "9xWqdCYS"
}
The standard product frontend will pass the mfa_session_uuid to the plugin frontend, which needs to pass it to the plugin backend. The plugin backend then calls this API.
During login binding:
GET https://{domain}/identity/api/org_users
Response:
{
"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
}
]
}
Use auth_user_uuid for binding.
Plugin call code example:
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. Exchange auth_user_uuid based on org_uuid and org_user_uuid
Used for batch importing user-phone binding relationship scenarios. The fetchOrganizationAuthUserUUIDs function provided above can be used.
About auth_user_uuid
The relationship between auth_user_uuid and user uuid is: one auth_user_uuid corresponds to multiple user uuids, because one person can have multiple organizations (SaaS). Within the organization, we use user uuid, while the parameters received by the plugin functions are all auth_user_uuid.
You can use the fetchOrganizationAuthUserUUIDs
function to get auth_user_uuid through organization uuid and user uuid.
Frontend Implementation
Slot Description
Slot Name | Slot Description |
---|---|
ones:global:authenticator:verify:new | MFA verification PC |
ones:global:authenticator:verify:h5:new | MFA verification H5 |
ones:global:authenticator:bind:new | MFA binding |
Note: MFA verification needs to implement both PC-side and H5-side slots separately, and they receive exactly the same parameters. MFA binding only has a PC-side slot; H5 does not support MFA binding.
MFA Verification Implementation
- Create PC-side verification page
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'))
})
- Create verification component
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="Enter verification code" crossOrigin />
</Form.Item>
<Form.Item>
<Button type="primary" block htmlType="submit">
Login
</Button>
</Form.Item>
</Form>
</div>
)
}
export default AuthenticatorVerify
MFA Binding Implementation
- Create binding page
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'))
})
- Create binding component
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="Enter phone number" crossOrigin />
</Form.Item>
<Form.Item rules={BASE_RULES} name="code">
<Input placeholder="Enter verification code" crossOrigin />
</Form.Item>
<Form.Item>
<Button type="primary" block htmlType="submit">
Bind
</Button>
</Form.Item>
</Form>
</div>
)
}
export default AuthenticatorBind
Error Handling
When the plugin needs to return a business error, it should be returned in the following format:
return {
statusCode: 200,
body: {
code: 400,
errcode: 'Plugin.CodeIsInvalid',
model: 'Plugin.Code',
reason: 'Invalid verification code',
type: 'error',
},
}
Note:
- statusCode must be 200
- body.code must be non-200
- body.errcode cannot be empty
System Configuration
Execute the following commands on the server terminal:
ones-ai-k8s.sh
vi config/private.yaml
Add or modify the following configurations as needed
To disable the system's default two-factor authentication method:
totpIsDisabled: true # Whether to disable TOTP two-factor authentication
If there are multiple two-factor authentication methods and you want to configure the recommended method:
recommendMfaMethod: cPw7h5Lc:smsProvider # Recommended two-factor authentication method
After modification, execute:
make setup-ones