Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Manage sms mfa through admin panel and s2s api #173

Merged
merged 2 commits into from
Sep 26, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@
- Google Sign-In
- Facebook Sign-In
- GitHub Sign-In
- <b>Multi-Factor Authentication</b>:
- <b>Multi-Factor Authentication</b> [How to setup MFA](https://auth.valuemelody.com/q_a.html#how-to-setup-mfa):
- Email MFA
- OTP MFA
- SMS MFA
Expand Down
8 changes: 7 additions & 1 deletion admin-panel/app/[lang]/dashboard/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,12 @@ const Page = () => {
<ConfigBooleanValue config={configs.SMS_MFA_IS_REQUIRED} />
</Table.Cell>
</Table.Row>
<Table.Row>
<Table.Cell>SMS_MFA_IS_REQUIRED</Table.Cell>
<Table.Cell>
<ConfigBooleanValue config={configs.SMS_MFA_IS_REQUIRED} />
</Table.Cell>
</Table.Row>
<Table.Row>
<Table.Cell>EMAIL_MFA_IS_REQUIRED</Table.Cell>
<Table.Cell>
Expand All @@ -214,7 +220,7 @@ const Page = () => {
<Table.Row>
<Table.Cell>ENFORCE_ONE_MFA_ENROLLMENT</Table.Cell>
<Table.Cell>
<ConfigBooleanValue config={configs.ENFORCE_ONE_MFA_ENROLLMENT} />
{configs.ENFORCE_ONE_MFA_ENROLLMENT.join(', ')}
</Table.Cell>
</Table.Row>
<Table.Row>
Expand Down
72 changes: 70 additions & 2 deletions admin-panel/app/[lang]/users/[authId]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ const Page = () => {

const isEmailEnrolled = configs.EMAIL_MFA_IS_REQUIRED || user?.mfaTypes.includes('email')
const isOtpEnrolled = configs.OTP_MFA_IS_REQUIRED || user?.mfaTypes.includes('otp')
const isSmsEnrolled = configs.SMS_MFA_IS_REQUIRED || user?.mfaTypes.includes('sms')

const updateObj = useMemo(
() => {
Expand Down Expand Up @@ -189,6 +190,16 @@ const Page = () => {
if (result) getUser()
}

const handleResetSmsMfa = async () => {
const token = await acquireToken()
const result = await proxyTool.sendNextRequest({
endpoint: `/api/users/${authId}/sms-mfa`,
method: 'DELETE',
token,
})
if (result) getUser()
}

const handleResetEmailMfa = async () => {
const token = await acquireToken()
const result = await proxyTool.sendNextRequest({
Expand All @@ -209,6 +220,16 @@ const Page = () => {
if (result) getUser()
}

const handleEnrollSmsMfa = async () => {
const token = await acquireToken()
const result = await proxyTool.sendNextRequest({
endpoint: `/api/users/${authId}/sms-mfa`,
method: 'POST',
token,
})
if (result) getUser()
}

const handleEnrollEmailMfa = async () => {
const token = await acquireToken()
const result = await proxyTool.sendNextRequest({
Expand Down Expand Up @@ -279,7 +300,7 @@ const Page = () => {
const renderOtpButtons = (user) => {
return (
<>
{(user.otpVerified || user.mfaTypes.includes('otp')) && user.isActive && (
{user.mfaTypes.includes('otp') && user.isActive && (
<Button
size='xs'
onClick={handleResetOtpMfa}
Expand All @@ -298,6 +319,28 @@ const Page = () => {
)
}

const renderSmsButtons = (user) => {
return (
<>
{user.mfaTypes.includes('sms') && user.isActive && (
<Button
size='xs'
onClick={handleResetSmsMfa}
>
{t('users.resetMfa')}
</Button>
)}
{user.isActive && !isSmsEnrolled && (
<Button
size='xs'
onClick={handleEnrollSmsMfa}>
{t('users.enrollMfa')}
</Button>
)}
</>
)
}

const renderIpButtons = (lockedIPs) => {
if (!lockedIPs.length) return null
return (
Expand Down Expand Up @@ -369,7 +412,7 @@ const Page = () => {
)}
{!user.socialAccountId && (
<Table.Row>
<Table.Cell>{t('users.authenticator')}</Table.Cell>
<Table.Cell>{t('users.otpMfa')}</Table.Cell>
<TableCell>
<div className='flex max-md:flex-col gap-2'>
{isOtpEnrolled && !user.otpVerified && (
Expand All @@ -392,6 +435,31 @@ const Page = () => {
</TableCell>
</Table.Row>
)}
{!user.socialAccountId && (
<Table.Row>
<Table.Cell>{t('users.smsMfa')}</Table.Cell>
<TableCell>
<div className='flex max-md:flex-col gap-2'>
{isSmsEnrolled && !user.smsPhoneNumberVerified && (
<div className='flex'>
<Badge color='gray'>{t('users.smsMfaEnrolled')}</Badge>
</div>
)}
{isSmsEnrolled && user.smsPhoneNumberVerified && (
<div className='flex'>
<Badge color='success'>{t('users.smsMfaVerified')}</Badge>
</div>
)}
<div className='md:hidden'>
{renderSmsButtons(user)}
</div>
</div>
</TableCell>
<TableCell className='max-md:hidden'>
{renderSmsButtons(user)}
</TableCell>
</Table.Row>
)}
<Table.Row>
<Table.Cell>{t('users.locale')}</Table.Cell>
<Table.Cell>
Expand Down
27 changes: 27 additions & 0 deletions admin-panel/app/api/users/[authId]/sms-mfa/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { sendS2SRequest } from 'app/api/request'

type Params = {
authId: string;
}

export async function POST (
request: Request, context: { params: Params },
) {
const authId = context.params.authId

return sendS2SRequest({
method: 'POST',
uri: `/api/v1/users/${authId}/sms-mfa`,
})
}

export async function DELETE (
request: Request, context: { params: Params },
) {
const authId = context.params.authId

return sendS2SRequest({
method: 'DELETE',
uri: `/api/v1/users/${authId}/sms-mfa`,
})
}
9 changes: 6 additions & 3 deletions admin-panel/translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -67,11 +67,14 @@
"emailVerified": "Email Verified",
"emailNotVerified": "Email not Verified",
"emailMfaEnrolled": "Email MFA Enrolled",
"otpMfaEnrolled": "Authenticator MFA Enrolled",
"otpMfaVerified": "Authenticator MFA Verified",
"otpMfaEnrolled": "OTP MFA Enrolled",
"otpMfaVerified": "OTP MFA Verified",
"smsMfaEnrolled": "SMS MFA Enrolled",
"smsMfaVerified": "SMS MFA Verified",
"resetMfa": "Reset MFA",
"enrollMfa": "Enroll MFA",
"authenticator": "Authenticator",
"otpMfa": "OTP MFA",
"smsMfa": "SMS MFA",
"firstName": "First Name",
"lastName": "Last Name",
"roles": "Roles",
Expand Down
9 changes: 6 additions & 3 deletions admin-panel/translations/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -67,11 +67,14 @@
"emailVerified": "E-mail vérifié",
"emailNotVerified": "E-mail non vérifié",
"emailMfaEnrolled": "MFA par e-mail activé",
"otpMfaEnrolled": "MFA par authentificateur activé",
"otpMfaVerified": "MFA par authentificateur vérifié",
"otpMfaEnrolled": "MFA par OTP activé",
"otpMfaVerified": "MFA par OTP vérifié",
"smsMfaEnrolled": "MFA par SMS activé",
"smsMfaVerified": "MFA par SMS vérifié",
"resetMfa": "Réinitialiser MFA",
"enrollMfa": "Inscrire MFA",
"authenticator": "Authentificateur",
"otpMfa": "MFA par OTP",
"smsMfa": "MFA par SMS",
"firstName": "Prénom",
"lastName": "Nom",
"roles": "Rôles",
Expand Down
5 changes: 5 additions & 0 deletions docs/q&a.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,3 +96,8 @@ npm run dev:secret:clean # For Cloudflare local env
npm run prod:secret:clean # For Cloudflare remote env
```
After running these commands, the old secret will be removed, and any tokens signed with the old secret will no longer be valid.

## How to setup MFA
- Enforcing specific MFA types: You can set OTP_MFA_IS_REQUIRED, SMS_MFA_IS_REQUIRED, or EMAIL_MFA_IS_REQUIRED to true to enforce those MFA methods as a login requirement.
- Letting users choose one of the supported MFA types: If OTP_MFA_IS_REQUIRED, SMS_MFA_IS_REQUIRED, and EMAIL_MFA_IS_REQUIRED are all set to false, you can set ENFORCE_ONE_MFA_ENROLLMENT to contain the MFA types you want to support. The user will then be required to enroll in one of the selected MFA types.
- You can also use the MFA enrollment functionality provided by the admin panel or the S2S API to customize your MFA enrollment flow.
95 changes: 94 additions & 1 deletion server/src/__tests__/normal/user.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import {
} from 'models'
import {
attachIndividualScopes,
dbTime, disableUser, enrollEmailMfa, enrollOtpMfa, getS2sToken,
dbTime, disableUser, enrollEmailMfa, enrollOtpMfa, getS2sToken, enrollSmsMfa,
} from 'tests/util'

let db: Database
Expand Down Expand Up @@ -57,6 +57,7 @@ const user1 = {
locale: 'en',
emailVerified: false,
otpVerified: false,
smsPhoneNumberVerified: false,
mfaTypes: [],
isActive: true,
loginCount: 0,
Expand All @@ -76,6 +77,7 @@ const user2 = {
locale: 'fr',
emailVerified: false,
otpVerified: false,
smsPhoneNumberVerified: false,
mfaTypes: [],
isActive: true,
loginCount: 0,
Expand Down Expand Up @@ -1202,3 +1204,94 @@ describe(
)
},
)

describe(
'enroll sms mfa',
() => {
const enrollAndCheckUser = async () => {
await insertUsers()

await app.request(
`${BaseRoute}/1-1-1-1/sms-mfa`,
{
method: 'POST',
headers: { Authorization: `Bearer ${await getS2sToken(db)}` },
},
mock(db),
)

const userRes = await app.request(
`${BaseRoute}/1-1-1-1`,
{ headers: { Authorization: `Bearer ${await getS2sToken(db)}` } },
mock(db),
)
return userRes
}

test(
'should enroll sms mfa',
async () => {
const userRes = await enrollAndCheckUser()

const userJson = await userRes.json() as { user: userModel.Record }
expect(userJson.user.mfaTypes).toStrictEqual(['sms'])
},
)

test(
'if sms is enforced by config',
async () => {
global.process.env.SMS_MFA_IS_REQUIRED = true as unknown as string
const userRes = await enrollAndCheckUser()

const userJson = await userRes.json() as { user: userModel.Record }
expect(userJson.user.mfaTypes).toStrictEqual([])
global.process.env.SMS_MFA_IS_REQUIRED = false as unknown as string
},
)
},
)

describe(
'Unenroll sms mfa',
() => {
const handleUnenrollCheck = async () => {
const res = await app.request(
`${BaseRoute}/1-1-1-1/sms-mfa`,
{
method: 'DELETE',
headers: { Authorization: `Bearer ${await getS2sToken(db)}` },
},
mock(db),
)
expect(res.status).toBe(204)

const userRes = await app.request(
`${BaseRoute}/1-1-1-1`,
{ headers: { Authorization: `Bearer ${await getS2sToken(db)}` } },
mock(db),
)

const userJson = await userRes.json() as { user: userModel.Record }
expect(userJson.user.mfaTypes).toStrictEqual([])
}

test(
'should unenroll sms mfa',
async () => {
await insertUsers()
await enrollSmsMfa(db)

await handleUnenrollCheck()
},
)

test(
'If user is not enrolled',
async () => {
await insertUsers()
await handleUnenrollCheck()
},
)
},
)
24 changes: 24 additions & 0 deletions server/src/handlers/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,18 @@ export const postUserOtpMfa = async (c: Context<typeConfig.Context>) => {
return c.body(null)
}

export const postUserSmsMfa = async (c: Context<typeConfig.Context>) => {
const authId = c.req.param('authId')

await userService.enrollUserMfa(
c,
authId,
userModel.MfaType.Sms,
)
c.status(204)
return c.body(null)
}

export const deleteUserEmailMfa = async (c: Context<typeConfig.Context>) => {
const authId = c.req.param('authId')

Expand All @@ -132,6 +144,18 @@ export const deleteUserOtpMfa = async (c: Context<typeConfig.Context>) => {
return c.body(null)
}

export const deleteUserSmsMfa = async (c: Context<typeConfig.Context>) => {
const authId = c.req.param('authId')

await userService.resetUserMfa(
c,
authId,
userModel.MfaType.Sms,
)
c.status(204)
return c.body(null)
}

export const deleteUserAppConsent = async (c: Context<typeConfig.Context>) => {
const authId = c.req.param('authId')
const appId = c.req.param('appId')
Expand Down
2 changes: 2 additions & 0 deletions server/src/models/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ export interface ApiRecord {
lastName?: string | null;
emailVerified: boolean;
otpVerified: boolean;
smsPhoneNumberVerified: boolean;
loginCount: number;
mfaTypes: string[];
isActive: boolean;
Expand Down Expand Up @@ -131,6 +132,7 @@ export const convertToApiRecord = (
email: record.email,
locale: record.locale,
emailVerified: record.emailVerified,
smsPhoneNumberVerified: record.smsPhoneNumberVerified,
otpVerified: record.otpVerified,
mfaTypes: record.mfaTypes,
isActive: record.isActive,
Expand Down
Loading
Loading