Skip to main content

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

  1. 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
}
  1. 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:

FieldDescriptionData ExampleRetrieval Method
languagesLanguage array["en"]body?.languages

It is recommended to return the corresponding verification method name according to different languages.

hasBound

Parameter description:

FieldDescriptionData Example
auth_user_uuidUser IDDAAprqQf

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:

FieldDescriptionData Example
session_idmfa_session_idP1cx1xZmn8ojEd1znKRFRB
auth_user_uuidUser IDDAAprqQf
codeVerification code123456

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:

FieldDescriptionData Example
session_idmfa_session_id
auth_user_uuidUser IDDAAprqQf
identifierDevice identifier, e.g., phone number12627860611
codeVerification code123456

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 NameSlot Description
ones:global:authenticator:verify:newMFA verification PC
ones:global:authenticator:verify:h5:newMFA verification H5
ones:global:authenticator:bind:newMFA 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

  1. 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'))
})
  1. 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

  1. 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'))
})
  1. 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