Skip to main content
Glama
mfa.ts5.31 kB
// SPDX-FileCopyrightText: Copyright Orangebot, Inc. and Medplum contributors // SPDX-License-Identifier: Apache-2.0 import { allOk, badRequest } from '@medplum/core'; import type { Login, Reference, User } from '@medplum/fhirtypes'; import type { Request, Response } from 'express'; import { Router } from 'express'; import { body, validationResult } from 'express-validator'; import { authenticator } from 'otplib'; import { toDataURL } from 'qrcode'; import { getConfig } from '../config/loader'; import { getAuthenticatedContext } from '../context'; import { invalidRequest, sendOutcome } from '../fhir/outcomes'; import { getSystemRepo } from '../fhir/repo'; import { authenticateRequest } from '../oauth/middleware'; import { verifyMfaToken } from '../oauth/utils'; import { sendLoginResult } from './utils'; export const mfaRouter = Router(); mfaRouter.get('/status', authenticateRequest, async (_req: Request, res: Response) => { const systemRepo = getSystemRepo(); const ctx = getAuthenticatedContext(); let user = await systemRepo.readReference<User>(ctx.membership.user as Reference<User>); if (user.mfaEnrolled) { res.json({ enrolled: true }); return; } if (!user.mfaSecret) { user = await systemRepo.updateResource({ ...user, mfaSecret: authenticator.generateSecret(), }); } const accountName = `Medplum - ${user.email}`; const issuer = 'medplum.com'; const secret = user.mfaSecret as string; const otp = authenticator.keyuri(accountName, issuer, secret); res.json({ enrolled: false, enrollUri: otp, enrollQrCode: await toDataURL(otp), }); }); mfaRouter.post( '/login-enroll', [body('login').notEmpty().withMessage('Missing login'), body('token').notEmpty().withMessage('Missing token')], async (req: Request, res: Response) => { const systemRepo = getSystemRepo(); const login = await systemRepo.readResource<Login>('Login', req.body.login); const user = await systemRepo.readReference<User>(login.user as Reference<User>); if (user.mfaEnrolled) { sendOutcome(res, badRequest('Already enrolled')); return; } if (!user.mfaSecret) { sendOutcome(res, badRequest('Secret not found')); return; } const result = await verifyMfaToken(login, req.body.token); await systemRepo.updateResource({ ...user, mfaEnrolled: true, }); await sendLoginResult(res, result); } ); mfaRouter.post( '/enroll', authenticateRequest, [body('token').notEmpty().withMessage('Missing token')], async (req: Request, res: Response) => { const systemRepo = getSystemRepo(); const ctx = getAuthenticatedContext(); const user = await systemRepo.readReference<User>(ctx.membership.user as Reference<User>); if (user.mfaEnrolled) { sendOutcome(res, badRequest('Already enrolled')); return; } if (!user.mfaSecret) { sendOutcome(res, badRequest('Secret not found')); return; } const secret = user.mfaSecret as string; const token = req.body.token as string; authenticator.options = { window: getConfig().mfaAuthenticatorWindow ?? 1 }; if (!authenticator.verify({ token, secret })) { sendOutcome(res, badRequest('Invalid token')); return; } await systemRepo.updateResource({ ...user, mfaEnrolled: true, }); sendOutcome(res, allOk); } ); mfaRouter.post( '/verify', [body('login').notEmpty().withMessage('Missing login'), body('token').notEmpty().withMessage('Missing token')], async (req: Request, res: Response) => { const errors = validationResult(req); if (!errors.isEmpty()) { sendOutcome(res, invalidRequest(errors)); return; } const systemRepo = getSystemRepo(); const login = await systemRepo.readResource<Login>('Login', req.body.login); const result = await verifyMfaToken(login, req.body.token); await sendLoginResult(res, result); } ); mfaRouter.post( '/disable', authenticateRequest, [body('token').notEmpty().withMessage('Missing token')], async (req: Request, res: Response) => { const systemRepo = getSystemRepo(); const ctx = getAuthenticatedContext(); const user = await systemRepo.readReference<User>(ctx.membership.user as Reference<User>); if (!user.mfaSecret) { sendOutcome(res, badRequest('Secret not found')); return; } if (!user.mfaEnrolled) { sendOutcome(res, badRequest('User not enrolled in MFA')); return; } const errors = validationResult(req); if (!errors.isEmpty()) { sendOutcome(res, invalidRequest(errors)); return; } const secret = user.mfaSecret as string; const token = req.body.token as string; authenticator.options = { window: getConfig().mfaAuthenticatorWindow ?? 1 }; if (!authenticator.verify({ token, secret })) { sendOutcome(res, badRequest('Invalid token')); return; } await systemRepo.updateResource({ ...user, mfaEnrolled: false, // We generate a new secret so that next time the user enrolls that they don't get the same secret // This allows for new secrets in the case of lost / stolen two-factor devices mfaSecret: authenticator.generateSecret(), }); sendOutcome(res, allOk); } );

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/medplum/medplum'

If you have feedback or need assistance with the MCP directory API, please join our Discord server