Skip to content

Commit

Permalink
Support OTP MFA reset by S2S API and admin panel (#101)
Browse files Browse the repository at this point in the history
  • Loading branch information
byn9826 authored Aug 14, 2024
1 parent c10900d commit dd1204a
Show file tree
Hide file tree
Showing 13 changed files with 204 additions and 53 deletions.
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
## Features Supported
- OAuth 2.0 Support (Authorize, Token Exchange, Token Revoke, App Consent, App Scopes, RSA256 based JWT Authentication)
- User Authorization (Sign In, Sign Up, Sign Out, Email Verification, Password Reset, Email MFA, OTP MFA, Brute-force Protection, Role-Based Access Control, Localization) [Screenshots](https://auth.valuemelody.com/screenshots.html#identity-pages-and-emails)
- Admin Panel (Manage Users, Manage Apps, Manage Scopes, Manage Roles) [Screenshots](https://auth.valuemelody.com/screenshots.html#admin-panel-pages)
- S2S REST API & Admin Panel (Manage Users, Manage Apps, Manage Scopes, Manage Roles) [Screenshots](https://auth.valuemelody.com/screenshots.html#admin-panel-pages)

## Why Melody Auth?

Expand All @@ -26,7 +26,7 @@
- Implements Proof Key for Code Exchange (PKCE) for enhanced security

### 4. Server-to-Server REST API
[Rest API Swagger](https://auth-server.valuemelody.com/api/v1/swagger)
[REST API Swagger](https://auth-server.valuemelody.com/api/v1/swagger)
- Secure communication channel for backend services using client credentials token exchange flow
- Provides functionalities for managing apps, users, scopes, and roles with scope protection

Expand Down
83 changes: 59 additions & 24 deletions admin-panel/app/[lang]/users/[authId]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import { useAuth } from '@melody-auth/react'
import {
Badge, Button, Card, Checkbox, Label, Select, Table,
TableCell,
TextInput,
ToggleSwitch,
} from 'flowbite-react'
Expand Down Expand Up @@ -171,11 +172,21 @@ const Page = () => {
endpoint: `/api/users/${authId}`,
method: 'POST',
token,
body: { action: 'verify-email' },
})
if (result) setEmailResent(true)
}

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

const handleToggleUserRole = (role: string) => {
const newRoles = userRoles.includes(role)
? userRoles.filter((userRole) => role !== userRole)
Expand Down Expand Up @@ -228,7 +239,53 @@ const Page = () => {
</Table.Row>
<Table.Row>
<Table.Cell>{t('users.email')}</Table.Cell>
<Table.Cell>{user.email}</Table.Cell>
<Table.Cell>
<div className='flex items-center gap-4'>
<p>{user.email}</p>
{configs.ENABLE_EMAIL_VERIFICATION && (
<UserEmailVerified user={user} />
)}
{configs.EMAIL_MFA_IS_REQUIRED && (
<Badge color='gray'>{t('users.emailMfaEnrolled')}</Badge>
)}
</div>
</Table.Cell>
<Table.Cell>
{configs.ENABLE_EMAIL_VERIFICATION && user.isActive && !user.emailVerified && !emailResent && (
<Button
size='xs'
onClick={handleResendVerifyEmail}>
{t('users.resend')}
</Button>
)}
{configs.ENABLE_EMAIL_VERIFICATION && user.isActive && !user.emailVerified && emailResent && (
<div className='flex'>
<Badge>{t('users.sent')}</Badge>
</div>
)}
</Table.Cell>
</Table.Row>
<Table.Row>
<Table.Cell>{t('users.authenticator')}</Table.Cell>
<TableCell>
<div className='flex'>
{configs.OTP_MFA_IS_REQUIRED && !user.otpVerified && (
<Badge color='gray'>{t('users.otpMfaEnrolled')}</Badge>
)}
{configs.OTP_MFA_IS_REQUIRED && user.otpVerified && (
<Badge color='success'>{t('users.otpMfaVerified')}</Badge>
)}
</div>
</TableCell>
<TableCell>
{user.otpVerified && user.isActive && (
<Button
size='xs'
onClick={handleResetOtpMfa}>
{t('users.reset')}
</Button>
)}
</TableCell>
</Table.Row>
<Table.Row>
<Table.Cell>{t('users.locale')}</Table.Cell>
Expand Down Expand Up @@ -266,28 +323,6 @@ const Page = () => {
)}
</Table.Cell>
</Table.Row>
{configs.ENABLE_EMAIL_VERIFICATION && (
<Table.Row>
<Table.Cell>{t('users.emailVerified')}</Table.Cell>
<Table.Cell>
<UserEmailVerified user={user} />
</Table.Cell>
<Table.Cell>
{user.isActive && !user.emailVerified && !emailResent && (
<Button
size='xs'
onClick={handleResendVerifyEmail}>
{t('users.resend')}
</Button>
)}
{user.isActive && !user.emailVerified && emailResent && (
<div className='flex'>
<Badge>{t('users.sent')}</Badge>
</div>
)}
</Table.Cell>
</Table.Row>
)}
{enableAccountLock && (
<Table.Row>
<Table.Cell>{t('users.lockedIPs')}</Table.Cell>
Expand Down
13 changes: 3 additions & 10 deletions admin-panel/app/[lang]/users/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import {
useEffect, useMemo, useState,
} from 'react'
import useCurrentLocale from 'hooks/useCurrentLocale'
import UserEmailVerified from 'components/UserEmailVerified'
import { proxyTool } from 'tools'
import EntityStatusLabel from 'components/EntityStatusLabel'
import EditLink from 'components/EditLink'
Expand Down Expand Up @@ -68,11 +67,10 @@ const Page = () => {
<Table>
<Table.Head>
<Table.HeadCell>{t('users.authId')}</Table.HeadCell>
<Table.HeadCell>{t('users.email')}</Table.HeadCell>
<Table.HeadCell>
{t('users.email')}
</Table.HeadCell>
<Table.HeadCell>{t('users.status')}</Table.HeadCell>
{configs.ENABLE_EMAIL_VERIFICATION && (
<Table.HeadCell>{t('users.emailVerified')}</Table.HeadCell>
)}
{configs.ENABLE_NAMES && (
<Table.HeadCell>{t('users.name')}</Table.HeadCell>
)}
Expand All @@ -91,11 +89,6 @@ const Page = () => {
<Table.Cell>
<EntityStatusLabel isEnabled={user.isActive} />
</Table.Cell>
{configs.ENABLE_EMAIL_VERIFICATION && (
<Table.Cell>
<UserEmailVerified user={user} />
</Table.Cell>
)}
{configs.ENABLE_NAMES && (
<Table.Cell>
{`${user.firstName ?? ''} ${user.lastName ?? ''}`}
Expand Down
16 changes: 16 additions & 0 deletions admin-panel/app/api/users/[authId]/otp-mfa/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { sendS2SRequest } from 'app/api/request'

type Params = {
authId: string;
}

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

return sendS2SRequest({
method: 'DELETE',
uri: `/api/v1/users/${authId}/otp-mfa`,
})
}
20 changes: 9 additions & 11 deletions admin-panel/components/UserEmailVerified.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,18 @@
import {
CheckIcon, XMarkIcon,
} from '@heroicons/react/16/solid'
import { Badge } from 'flowbite-react'
import { useTranslations } from 'next-intl'

const UserEmailVerified = ({ user }) => {
const t = useTranslations()
return user.emailVerified
? (
<CheckIcon
className='w-4 h-4'
color='green'
/>
<Badge color='success'>
{t('users.emailVerified')}
</Badge>
)
: (
<XMarkIcon
className='w-4 h-4'
color='red'
/>
<Badge color='failure'>
{t('users.emailNotVerified')}
</Badge>
)
}

Expand Down
12 changes: 9 additions & 3 deletions admin-panel/translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -61,12 +61,18 @@
"locale": "Locale",
"status": "Status",
"loginCount": "Login Count",
"emailVerified": "Email Is Verified",
"emailVerified": "Email Verified",
"emailNotVerified": "Email not Verified",
"emailMfaEnrolled": "Email MFA Enrolled",
"otpMfaEnrolled": "Authenticator MFA Enrolled",
"otpMfaVerified": "Authenticator MFA Verified",
"reset": "Reset",
"authenticator": "Authenticator",
"firstName": "First Name",
"lastName": "Last Name",
"roles": "Roles",
"resend": "Resend Email",
"sent": "Email Sent",
"resend": "Resend Verification Email",
"sent": "Verification Email Sent",
"you": "You",
"consented": "Consented Apps",
"revokeConsent": "Revoke",
Expand Down
12 changes: 9 additions & 3 deletions admin-panel/translations/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -61,12 +61,18 @@
"locale": "Langue",
"status": "Statut",
"loginCount": "Nombre de connexions",
"emailVerified": "Email vérifié",
"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é",
"reset": "Réinitialiser",
"authenticator": "Authentificateur",
"firstName": "Prénom",
"lastName": "Nom",
"roles": "Rôles",
"resend": "Renvoyer l'email",
"sent": "Email envoyé",
"resend": "Renvoyer l'e-mail de vérification",
"sent": "E-mail de vérification envoyé",
"you": "Vous",
"consented": "Applications consenties",
"revokeConsent": "Révoquer",
Expand Down
11 changes: 11 additions & 0 deletions server/src/handlers/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,17 @@ export const deleteUserLockedIPs = async (c: Context<typeConfig.Context>) => {
return c.body(null)
}

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

await userService.resetUserOtpMfa(
c,
authId,
)
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 @@ -44,6 +44,7 @@ export interface ApiRecord {
firstName?: string | null;
lastName?: string | null;
emailVerified: boolean;
otpVerified: boolean;
loginCount: number;
isActive: boolean;
createdAt: string;
Expand Down Expand Up @@ -104,6 +105,7 @@ export const convertToApiRecord = (
email: record.email,
locale: record.locale,
emailVerified: record.emailVerified,
otpVerified: record.otpVerified,
isActive: record.isActive,
loginCount: record.loginCount,
createdAt: record.createdAt,
Expand Down
24 changes: 24 additions & 0 deletions server/src/routes/user.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -296,3 +296,27 @@ userRoutes.delete(
authMiddleware.s2sWriteUser,
userHandler.deleteUserAppConsent,
)

/**
* @swagger
* /api/v1/users/{authId}/otp-mfa:
* delete:
* summary: Remove user's current authenticator MFA setup.
* description: Required scope - write_user
* tags: [Users]
* parameters:
* - in: path
* name: authId
* required: true
* schema:
* type: string
* description: The authId of the user
* responses:
* 204:
* description: Successful operation with no content to return
*/
userRoutes.delete(
`${BaseRoute}/:authId/otp-mfa`,
authMiddleware.s2sWriteUser,
userHandler.deleteUserOtpMfa,
)
1 change: 1 addition & 0 deletions server/src/scripts/schemas/user.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ const User = {
},
locale: { type: 'string' },
emailVerified: { type: 'boolean' },
otpVerified: { type: 'boolean' },
isActive: { type: 'boolean' },
createdAt: { type: 'string' },
updatedAt: { type: 'string' },
Expand Down
28 changes: 28 additions & 0 deletions server/src/scripts/swagger.json
Original file line number Diff line number Diff line change
Expand Up @@ -373,6 +373,9 @@
"emailVerified": {
"type": "boolean"
},
"otpVerified": {
"type": "boolean"
},
"isActive": {
"type": "boolean"
},
Expand Down Expand Up @@ -1271,6 +1274,31 @@
}
}
}
},
"/api/v1/users/{authId}/otp-mfa": {
"delete": {
"summary": "Remove user's current authenticator MFA setup.",
"description": "Required scope - write_user",
"tags": [
"Users"
],
"parameters": [
{
"in": "path",
"name": "authId",
"required": true,
"schema": {
"type": "string"
},
"description": "The authId of the user"
}
],
"responses": {
"204": {
"description": "Successful operation with no content to return"
}
}
}
}
},
"tags": []
Expand Down
Loading

0 comments on commit dd1204a

Please sign in to comment.